discourse/plugins/discourse-patreon/plugin.rb
Rafael dos Santos Silva 8c0a4bb8f9
DEV: Upgrade Patreon plugin to API v2 with v1 backward compatibility (#38871)
## Summary

Patreon API v1 has been deprecated for years and the Patreon team has
requested we migrate to v2 (see
https://meta.discourse.org/t/upgrade-patreon-discourse-plugin-to-api-v2/386701).

Since hundreds of existing customers have v1 OAuth clients, this PR
supports **both versions simultaneously** via an adapter pattern,
controlled by a new `patreon_api_version` site setting (defaults to
`"1"` so no one breaks on upgrade).

### What changed

- **Adapter pattern**: `Patreon::ApiVersion::V1` and `ApiVersion::V2`
modules with identical interfaces for endpoints, response parsing, and
OAuth config. `ApiVersion.current` routes based on the site setting.
- **v2 API support**: campaigns endpoint returns tiers (not rewards),
members fetched separately with cursor-based pagination, v2 identity
endpoint with explicit field selection, v2 OAuth scopes
- **Webhooks accept both formats**: payload version detected from
`data.type` (`"pledge"` → v1, `"member"` → v2), regardless of the site
setting
- **Admin deprecation notice**: `ProblemCheck::PatreonApiV1Deprecated`
warns on the dashboard when still using v1
- **Shared logic unchanged**: rate limiting, PluginStore, group sync,
admin UI all version-agnostic
- Also fixes pre-existing flaky seed/campaign spec failures

### Migration path for existing users

1. Admin sees deprecation warning on dashboard
2. Create a new v2 OAuth client at
https://www.patreon.com/portal/registration/register-clients
3. Update credentials in site settings
4. Switch `patreon_api_version` to `"2"`
5. Creator re-authenticates via Patreon login to get v2-scoped tokens
6. Trigger a manual data sync from the Patreon admin panel

### Future v1 removal

When ready to drop v1: delete `lib/api_version/v1.rb`, the problem
check, the site setting, v1 fixtures, and v1 test contexts — one clean
cut.

## Test plan

- [x] All 41 plugin specs pass (both v1 and v2 contexts)
- [x] Lint passes on all changed files
- [x] Zeitwerk reload passes (`bin/rails runner
"Rails.application.reloader.reload!"`)
- [x] Manual testing with a real Patreon v2 OAuth client
2026-03-30 14:43:34 -03:00

205 lines
6.5 KiB
Ruby

# frozen_string_literal: true
# name: discourse-patreon
# about: Enables Patreon Social Login, and synchronization between Discourse Groups and Patreon rewards.
# meta_topic_id: 44366
# version: 2.0
# author: Rafael dos Santos Silva <xfalcox@gmail.com>
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-patreon
require "omniauth-oauth2"
enabled_site_setting :patreon_enabled
register_asset "stylesheets/patreon.scss"
register_svg_icon "fab-patreon"
register_svg_icon "patreon-new"
# Site setting validators must be loaded before initialize
require_relative "lib/validators/patreon_login_enabled_validator"
module ::Patreon
PLUGIN_NAME = "discourse-patreon"
end
require_relative "lib/discourse_patreon/engine"
after_initialize do
require_dependency "admin_constraint"
require_relative "lib/api_version/v1"
require_relative "lib/api_version/v2"
require_relative "lib/api"
require_relative "app/controllers/patreon/patreon_admin_controller"
require_relative "app/controllers/patreon/patreon_webhook_controller"
require_relative "app/jobs/regular/sync_patron_groups"
require_relative "app/jobs/scheduled/patreon_sync_patrons_to_groups"
require_relative "app/jobs/scheduled/patreon_update_tokens"
require_relative "app/services/problem_check/access_token_invalid"
require_relative "app/services/problem_check/patreon_api_v1_deprecated"
require_relative "lib/seed"
require_relative "lib/campaign"
require_relative "lib/pledge"
require_relative "lib/patron"
require_relative "lib/tokens"
Discourse::Application.routes.prepend { mount Patreon::Engine, at: "/patreon" }
add_admin_route "patreon.title", "patreon"
Discourse::Application.routes.append do
get "/admin/plugins/patreon" => "admin/plugins#index", :constraints => AdminConstraint.new
get "/admin/plugins/patreon/list" => "patreon/patreon_admin#list",
:constraints => AdminConstraint.new
get "/u/:username/patreon_email" => "patreon/patreon_admin#email",
:constraints => {
username: RouteFormat.username,
}
end
on(:user_created) do |user|
filters = PluginStore.get(Patreon::PLUGIN_NAME, "filters")
patreon_id = Patreon::Patron.all.key(user.email)
if filters.present? && patreon_id.present?
begin
reward_id =
Patreon::RewardUser.all.except("0").detect { |_k, v| v.include? patreon_id }&.first
group_ids = filters.select { |_k, v| v.include?(reward_id) || v.include?("0") }.keys
Group.where(id: group_ids).each { |group| group.add user }
Patreon::Patron.update_local_user(user, patreon_id, true)
rescue => e
Rails.logger.warn(
"Patreon group membership callback failed for new user #{self.id} with error: #{e}.\n\n #{e.backtrace.join("\n")}",
)
end
end
end
Patreon::USER_DETAIL_FIELDS.each do |attribute|
add_to_serializer(
:admin_detailed_user,
"patreon_#{attribute}".to_sym,
include_condition: -> do
Patreon::Patron.attr(attribute, object).present? &&
(attribute != "amount_cents" || scope.is_admin?)
end,
) { Patreon::Patron.attr(attribute, object) }
end
add_to_serializer(
:admin_detailed_user,
:patreon_email_exists,
include_condition: -> { Patreon::Patron.attr("email", object).present? },
) { true }
add_to_serializer(:current_user, :show_donation_prompt?) do
Patreon.show_donation_prompt_to_user?(object)
end
register_problem_check ProblemCheck::AccessTokenInvalid
register_problem_check ProblemCheck::PatreonApiV1Deprecated
end
# Authentication with Patreon
class ::OmniAuth::Strategies::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://api.patreon.com/oauth2/token"
option :authorize_params, response_type: "code"
def custom_build_access_token
verifier = request.params["code"]
client.auth_code.get_token(verifier, redirect_uri: options.redirect_uri)
end
alias_method :build_access_token, :custom_build_access_token
uid { raw_info["data"]["id"].to_s }
info do
{
email: raw_info["data"]["attributes"]["email"],
email_verified: raw_info["data"]["attributes"]["is_email_verified"],
name: raw_info["data"]["attributes"]["full_name"],
}
end
extra { { raw_info: raw_info } }
def raw_info
@raw_info ||=
begin
response =
client.request(
:get,
Patreon::ApiVersion.current.oauth_identity_url,
headers: {
"Authorization" => "Bearer #{access_token.token}",
},
parse: :json,
)
response.parsed
end
end
def callback_url
full_host + script_name + callback_path
end
end
class Auth::PatreonAuthenticator < Auth::ManagedAuthenticator
def name
"patreon"
end
def register_middleware(omniauth)
omniauth.provider :patreon,
setup:
lambda { |env|
strategy = env["omniauth.strategy"]
adapter = Patreon::ApiVersion.current
strategy.options[:client_id] = SiteSetting.patreon_client_id
strategy.options[:client_secret] = SiteSetting.patreon_client_secret
strategy.options[
:redirect_uri
] = "#{Discourse.base_url}/auth/patreon/callback"
strategy.options[
:provider_ignores_state
] = SiteSetting.patreon_login_ignore_state
strategy.options[:client_options][:token_url] = adapter.oauth_token_url
strategy.options[:authorize_params] = adapter.oauth_authorize_params
}
end
def after_authenticate(auth_token, existing_account: nil)
result = super
user = result.user
discourse_username = SiteSetting.patreon_creator_discourse_username
if discourse_username.present? && user && user.username == discourse_username
SiteSetting.patreon_creator_access_token = auth_token.credentials["token"]
SiteSetting.patreon_creator_refresh_token = auth_token.credentials["refresh_token"]
end
result
end
def enabled?
SiteSetting.patreon_login_enabled
end
def primary_email_verified?(auth_token)
auth_token[:info][:email_verified]
end
end
auth_provider authenticator: Auth::PatreonAuthenticator.new, icon: "patreon-new"