diff --git a/app/services/patreon/patron_checker.rb b/app/services/patreon/patron_checker.rb new file mode 100644 index 00000000..575f83f6 --- /dev/null +++ b/app/services/patreon/patron_checker.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Patreon + # Service to check Patreon patron status + class PatronChecker + attr_reader :user + + def initialize(user) + @user = user + end + + # Check if user is a patron of a specific creator + # @param creator_id [String] The Patreon creator ID + # @return [Boolean] true if user is an active patron + def patron_of?(creator_id) + memberships.any? do |membership| + membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s && + membership.dig('attributes', 'patron_status') == 'active_patron' + end + end + + # Get all active memberships + # @return [Array] Array of membership data with campaign info + def memberships + @memberships ||= fetch_memberships + end + + # Get detailed membership info for a specific creator + # @param creator_id [String] The Patreon creator ID + # @return [Hash, nil] Membership details or nil if not a patron + def membership_for(creator_id) + memberships.find do |membership| + membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s + end + end + + private + + def fetch_memberships + return [] unless valid_token? + + response = make_api_request + return [] unless response + + extract_memberships(response) + rescue StandardError => e + Rails.logger.error("Failed to fetch Patreon memberships: #{e.message}") + [] + end + + def valid_token? + return false if user.patreon_access_token.blank? + + # Check if token is expired + if user.patreon_token_expires_at && user.patreon_token_expires_at < Time.current + refresh_token! + end + + user.patreon_access_token.present? + end + + def refresh_token! + return false if user.patreon_refresh_token.blank? + + conn = Faraday.new(url: 'https://www.patreon.com') do |f| + f.request :url_encoded + f.response :json + f.adapter Faraday.default_adapter + end + + response = conn.post('/api/oauth2/token') do |req| + req.body = { + grant_type: 'refresh_token', + refresh_token: user.patreon_refresh_token, + client_id: ENV['PATREON_CLIENT_ID'], + client_secret: ENV['PATREON_CLIENT_SECRET'] + } + end + + if response.success? + data = response.body + user.update( + patreon_access_token: data['access_token'], + patreon_refresh_token: data['refresh_token'], + patreon_token_expires_at: Time.current + data['expires_in'].seconds + ) + true + else + Rails.logger.error("Failed to refresh Patreon token: #{response.body}") + false + end + rescue StandardError => e + Rails.logger.error("Error refreshing Patreon token: #{e.message}") + false + end + + def make_api_request + conn = Faraday.new(url: 'https://www.patreon.com') do |f| + f.request :url_encoded + f.response :json + f.adapter Faraday.default_adapter + end + + response = conn.get('/api/oauth2/v2/identity') do |req| + req.headers['Authorization'] = "Bearer #{user.patreon_access_token}" + req.params = { + include: 'memberships,memberships.campaign', + 'fields[member]' => 'patron_status,pledge_relationship_start', + 'fields[campaign]' => 'vanity,url' + } + end + + response.success? ? response.body : nil + end + + def extract_memberships(response) + return [] unless response['included'] + + memberships = response['included'].select { |item| item['type'] == 'member' } + campaigns = response['included'].select { |item| item['type'] == 'campaign' } + + # Enrich memberships with campaign data + memberships.map do |membership| + campaign_id = membership.dig('relationships', 'campaign', 'data', 'id') + campaign = campaigns.find { |c| c['id'] == campaign_id } + + membership.merge('campaign' => campaign) if campaign + end.compact + end + end +end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000..ae8c8277 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Load custom OmniAuth strategies +require Rails.root.join('lib', 'omniauth', 'strategies', 'patreon') diff --git a/db/migrate/20251028130433_add_omniauth_to_users.rb b/db/migrate/20251028130433_add_omniauth_to_users.rb new file mode 100644 index 00000000..01c61b71 --- /dev/null +++ b/db/migrate/20251028130433_add_omniauth_to_users.rb @@ -0,0 +1,6 @@ +class AddOmniauthToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :provider, :string + add_column :users, :uid, :string + end +end diff --git a/db/migrate/20251028160950_add_patreon_token_to_users.rb b/db/migrate/20251028160950_add_patreon_token_to_users.rb new file mode 100644 index 00000000..77cd928f --- /dev/null +++ b/db/migrate/20251028160950_add_patreon_token_to_users.rb @@ -0,0 +1,7 @@ +class AddPatreonTokenToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :patreon_access_token, :text + add_column :users, :patreon_refresh_token, :text + add_column :users, :patreon_token_expires_at, :datetime + end +end diff --git a/docs/PATREON_INTEGRATION.md b/docs/PATREON_INTEGRATION.md new file mode 100644 index 00000000..dd35e938 --- /dev/null +++ b/docs/PATREON_INTEGRATION.md @@ -0,0 +1,194 @@ +# Patreon Integration + +Dawarich Cloud includes Patreon OAuth integration that allows users to connect their Patreon accounts. This enables checking if users are patrons of specific creators. + +## Features + +- **OAuth Authentication**: Users can connect their Patreon accounts via OAuth 2.0 +- **Patron Status Checking**: Check if a user is an active patron of specific creators +- **Membership Data**: Access detailed information about user's Patreon memberships +- **Token Management**: Automatic token refresh to maintain API access + +## Setup + +### Environment Variables + +Configure the following environment variables for Dawarich Cloud: + +```bash +PATREON_CLIENT_ID=your_patreon_client_id +PATREON_CLIENT_SECRET=your_patreon_client_secret +``` + +### Getting Patreon OAuth Credentials + +1. Go to [Patreon Developer Portal](https://www.patreon.com/portal/registration/register-clients) +2. Create a new OAuth client +3. Set the redirect URI to: `https://your-domain.com/users/auth/patreon/callback` +4. Copy the Client ID and Client Secret + +## Usage + +### User Connection + +Users can connect their Patreon account in the account settings: + +1. Navigate to Settings +2. Find the "Connected Accounts" section +3. Click "Connect" next to Patreon +4. Authorize the application on Patreon +5. Get redirected back to Dawarich + +### Checking Patron Status + +#### Check if User is a Patron of Specific Creator + +```ruby +# Get Dawarich creator's Patreon ID (find it in your Patreon campaign URL) +dawarich_creator_id = 'your_creator_id' + +# Check if current user is a patron +if current_user.patron_of?(dawarich_creator_id) + # User is an active patron! + # Grant special features, show badge, etc. +end +``` + +#### Get All Memberships + +```ruby +# Get all campaigns the user is supporting +memberships = current_user.patreon_memberships + +memberships.each do |membership| + campaign = membership['campaign'] + + puts "Supporting: #{campaign['attributes']['vanity']}" + puts "URL: #{campaign['attributes']['url']}" + puts "Status: #{membership['attributes']['patron_status']}" + puts "Since: #{membership['attributes']['pledge_relationship_start']}" +end +``` + +#### Get Specific Membership Details + +```ruby +creator_id = 'your_creator_id' + +membership = Patreon::PatronChecker.new(current_user).membership_for(creator_id) + +if membership + # User is a patron + status = membership.dig('attributes', 'patron_status') + started_at = membership.dig('attributes', 'pledge_relationship_start') + + # Access campaign details + campaign = membership['campaign'] + campaign_name = campaign.dig('attributes', 'vanity') +end +``` + +### Patron Status Values + +The `patron_status` field can have the following values: + +- `active_patron` - Currently an active patron +- `declined_patron` - Payment declined +- `former_patron` - Was a patron but not anymore + +### Example: Show Patron Badge + +```ruby +# In a view or helper +def show_patron_badge?(user) + dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] + return false unless dawarich_creator_id.present? + + user.patron_of?(dawarich_creator_id) +end +``` + +```erb + +<% if show_patron_badge?(current_user) %> + + ❤️ Patreon Supporter + +<% end %> +``` + +### Example: Grant Premium Features + +```ruby +class User < ApplicationRecord + def premium_access? + return true if admin? + return true if active_subscription? # existing subscription logic + + # Check Patreon support + dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] + return false unless dawarich_creator_id + + patron_of?(dawarich_creator_id) + end +end +``` + +## Token Management + +The integration automatically handles token refresh: + +- Access tokens are stored securely in the database +- Tokens are automatically refreshed when expired +- If refresh fails, the user needs to reconnect their account + +## Disconnecting Patreon + +Users can disconnect their Patreon account at any time: + +```ruby +current_user.disconnect_patreon! +``` + +This will: +- Remove the provider/uid linkage +- Clear all stored tokens +- Revoke API access + +## Security Considerations + +- Access tokens are stored in the database (consider encrypting at rest) +- Tokens are automatically refreshed to maintain access +- API requests are made server-side only +- Users can revoke access at any time from their Patreon settings + +## API Rate Limits + +Patreon API has rate limits. The service handles this by: +- Caching membership data when possible +- Using efficient API queries +- Handling API errors gracefully + +## Troubleshooting + +### Token Expired Errors + +If users see authentication errors: +1. Ask them to disconnect and reconnect their Patreon account +2. Check that the refresh token is still valid +3. Verify environment variables are set correctly + +### Creator ID Not Found + +To find a Patreon creator ID: +1. Go to the creator's Patreon page +2. Use the Patreon API: `GET https://www.patreon.com/api/oauth2/v2/campaigns` +3. The campaign ID is the creator ID you need + +## Future Enhancements + +Potential future features: +- Tier-based access (check pledge amount) +- Lifetime pledge amount tracking +- Patron anniversary badges +- Direct campaign data caching diff --git a/lib/omniauth/strategies/patreon.rb b/lib/omniauth/strategies/patreon.rb new file mode 100644 index 00000000..f6103e3e --- /dev/null +++ b/lib/omniauth/strategies/patreon.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'omniauth-oauth2' + +module OmniAuth + module Strategies + # OmniAuth strategy for Patreon OAuth2 + class Patreon < OmniAuth::Strategies::OAuth2 + option :name, 'patreon' + + option :client_options, + site: 'https://www.patreon.com', + authorize_url: 'https://www.patreon.com/oauth2/authorize', + token_url: 'https://www.patreon.com/api/oauth2/token' + + option :authorize_params, + scope: 'identity identity[email]' + + uid { raw_info['data']['id'] } + + info do + { + email: raw_info.dig('data', 'attributes', 'email'), + name: raw_info.dig('data', 'attributes', 'full_name'), + first_name: raw_info.dig('data', 'attributes', 'first_name'), + last_name: raw_info.dig('data', 'attributes', 'last_name'), + image: raw_info.dig('data', 'attributes', 'image_url') + } + end + + extra do + { + raw_info: raw_info + } + end + + def raw_info + @raw_info ||= access_token.get('/api/oauth2/v2/identity?include=memberships&fields[user]=email,first_name,full_name,last_name,image_url').parsed + end + + def callback_url + full_host + callback_path + end + end + end +end