mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Add patreon account linking and patron checking service
This commit is contained in:
parent
7bc579e563
commit
5a40f9fe90
6 changed files with 388 additions and 0 deletions
131
app/services/patreon/patron_checker.rb
Normal file
131
app/services/patreon/patron_checker.rb
Normal file
|
|
@ -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<Hash>] 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
|
||||||
4
config/initializers/omniauth.rb
Normal file
4
config/initializers/omniauth.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Load custom OmniAuth strategies
|
||||||
|
require Rails.root.join('lib', 'omniauth', 'strategies', 'patreon')
|
||||||
6
db/migrate/20251028130433_add_omniauth_to_users.rb
Normal file
6
db/migrate/20251028130433_add_omniauth_to_users.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddOmniauthToUsers < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :users, :provider, :string
|
||||||
|
add_column :users, :uid, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
7
db/migrate/20251028160950_add_patreon_token_to_users.rb
Normal file
7
db/migrate/20251028160950_add_patreon_token_to_users.rb
Normal file
|
|
@ -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
|
||||||
194
docs/PATREON_INTEGRATION.md
Normal file
194
docs/PATREON_INTEGRATION.md
Normal file
|
|
@ -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
|
||||||
|
<!-- In a view -->
|
||||||
|
<% if show_patron_badge?(current_user) %>
|
||||||
|
<span class="badge badge-primary">
|
||||||
|
❤️ Patreon Supporter
|
||||||
|
</span>
|
||||||
|
<% 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
|
||||||
46
lib/omniauth/strategies/patreon.rb
Normal file
46
lib/omniauth/strategies/patreon.rb
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue