diff --git a/app/controllers/api/v1/donation_campaigns_controller.rb b/app/controllers/api/v1/donation_campaigns_controller.rb new file mode 100644 index 00000000000..cdd7503b304 --- /dev/null +++ b/app/controllers/api/v1/donation_campaigns_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class Api::V1::DonationCampaignsController < Api::BaseController + before_action :require_user! + + STOPLIGHT_COOL_OFF_TIME = 60 + STOPLIGHT_FAILURE_THRESHOLD = 10 + + def index + return head 204 if api_url.blank? + + json = from_cache + return render json: json if json.present? + + campaign = fetch_campaign + return head 204 if campaign.nil? + + save_to_cache!(campaign) + + render json: campaign + end + + private + + def api_url + Rails.configuration.x.donation_campaigns.api_url + end + + def seed + @seed ||= Random.new(current_account.id).rand(100) + end + + def from_cache + key = Rails.cache.read(request_key, raw: true) + return if key.blank? + + campaign = Rails.cache.read("donation_campaign:#{key}", raw: true) + Oj.load(campaign) if campaign.present? + end + + def save_to_cache!(campaign) + return if campaign.blank? + + Rails.cache.write_multi( + { + request_key => campaign_key(campaign), + "donation_campaign:#{campaign_key(campaign)}" => Oj.dump(campaign), + }, + expires_in: 1.hour, + raw: true + ) + end + + def fetch_campaign + stoplight_wrapper.run do + url = Addressable::URI.parse(api_url) + url.query_values = { platform: 'web', seed: seed, locale: locale, environment: Rails.configuration.x.donation_campaigns.environment }.compact + + Request.new(:get, url.to_s).perform do |res| + return Oj.load(res.body_with_limit, mode: :strict) if res.code == 200 + end + end + rescue *Mastodon::HTTP_CONNECTION_ERRORS, Oj::ParseError + nil + end + + def stoplight_wrapper + Stoplight( + 'donation_campaigns', + cool_off_time: STOPLIGHT_COOL_OFF_TIME, + threshold: STOPLIGHT_FAILURE_THRESHOLD + ) + end + + def request_key + "donation_campaign_request:#{seed}:#{locale}" + end + + def campaign_key(campaign) + "#{campaign['id']}:#{campaign['locale']}" + end + + def locale + I18n.locale.to_s + end +end diff --git a/config/mastodon.yml b/config/mastodon.yml index 4585e1f2aee..0177bf85e5e 100644 --- a/config/mastodon.yml +++ b/config/mastodon.yml @@ -4,6 +4,9 @@ shared: limited_federation_mode: <%= (ENV.fetch('LIMITED_FEDERATION_MODE', nil) || ENV.fetch('WHITELIST_MODE', nil)) == 'true' %> self_destruct_value: <%= ENV.fetch('SELF_DESTRUCT', nil)&.to_json %> software_update_url: <%= ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check')&.to_json %> + donation_campaigns: + api_url: <%= ENV.fetch('DONATION_CAMPAIGNS_URL', nil)&.to_json %> + environment: <%= ENV.fetch('DONATION_CAMPAIGNS_ENVIRONMENT', nil)&.to_json %> source: base_url: <%= ENV.fetch('SOURCE_BASE_URL', nil)&.to_json %> repository: <%= ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon') %> diff --git a/config/routes/api.rb b/config/routes/api.rb index 3fa1aa7af20..83555680dfa 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -74,6 +74,7 @@ namespace :api, format: false do resources :suggestions, only: [:index, :destroy] resources :scheduled_statuses, only: [:index, :show, :update, :destroy] resources :preferences, only: [:index] + resources :donation_campaigns, only: [:index] resources :annual_reports, only: [:index, :show] do member do diff --git a/spec/requests/api/v1/donation_campaigns_spec.rb b/spec/requests/api/v1/donation_campaigns_spec.rb new file mode 100644 index 00000000000..2ab3fb8e8a6 --- /dev/null +++ b/spec/requests/api/v1/donation_campaigns_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Donation campaigns' do + include_context 'with API authentication' + + describe 'GET /api/v1/donation_campaigns' do + context 'when not authenticated' do + it 'returns http unprocessable entity' do + get '/api/v1/donation_campaigns' + + expect(response) + .to have_http_status(422) + expect(response.content_type) + .to start_with('application/json') + end + end + + context 'when no donation campaign API is set up' do + it 'returns http empty' do + get '/api/v1/donation_campaigns', headers: headers + + expect(response) + .to have_http_status(204) + end + end + + context 'when a donation campaign API is set up' do + let(:api_url) { 'https://example.org/donations' } + let(:seed) { Random.new(user.account_id).rand(100) } + + around do |example| + original = Rails.configuration.x.donation_campaigns.api_url + Rails.configuration.x.donation_campaigns.api_url = api_url + + example.run + + Rails.configuration.x.donation_campaigns.api_url = original + end + + context 'when the donation campaign API does not return a campaign' do + before do + stub_request(:get, "#{api_url}?platform=web&seed=#{seed}&locale=en").to_return(status: 204) + end + + it 'returns http empty' do + get '/api/v1/donation_campaigns', headers: headers + + expect(response) + .to have_http_status(204) + end + end + + context 'when the donation campaign API returns a campaign' do + let(:campaign_json) do + { + 'id' => 'campaign-1', + 'banner_message' => 'Hi', + 'banner_button_text' => 'Donate!', + 'donation_message' => 'Hi!', + 'donation_button_text' => 'Money', + 'donation_success_post' => 'Success post', + 'amounts' => { + 'one_time' => { + 'EUR' => [1, 2, 3], + 'USD' => [4, 5, 6], + }, + 'monthly' => { + 'EUR' => [1], + 'USD' => [2], + }, + }, + 'default_currency' => 'EUR', + 'donation_url' => 'https://sponsor.joinmastodon.org/donate/new', + 'locale' => 'en', + } + end + + before do + stub_request(:get, "#{api_url}?platform=web&seed=#{seed}&locale=en").to_return(body: Oj.dump(campaign_json), status: 200) + end + + it 'returns the expected campaign' do + get '/api/v1/donation_campaigns', headers: headers + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to match(campaign_json) + + expect(Rails.cache.read("donation_campaign_request:#{seed}:en", raw: true)) + .to eq 'campaign-1:en' + + expect(Oj.load(Rails.cache.read('donation_campaign:campaign-1:en', raw: true))) + .to match(campaign_json) + end + end + end + end +end