mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-01 11:47:16 +08:00
## 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
205 lines
6.5 KiB
Ruby
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"
|