diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 3b0cda7d931..17062eb49f9 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -1,16 +1,19 @@ # frozen_string_literal: true class Api::V1::Push::SubscriptionsController < Api::BaseController + include DeprecationConcern include Redisable include Lockable + deprecate_api '2026-12-31' + before_action -> { doorkeeper_authorize! :push } before_action :require_user! before_action :set_push_subscription, only: [:show, :update] before_action :check_push_subscription, only: [:show, :update] def show - render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionV1Serializer end def create @@ -19,12 +22,12 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController @push_subscription = Web::PushSubscription.create!(web_push_subscription_params) end - render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionV1Serializer end def update @push_subscription.update!(data: data_params) - render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionV1Serializer end def destroy diff --git a/app/controllers/api/v2/push/subscriptions_controller.rb b/app/controllers/api/v2/push/subscriptions_controller.rb new file mode 100644 index 00000000000..673e3e75deb --- /dev/null +++ b/app/controllers/api/v2/push/subscriptions_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Api::V2::Push::SubscriptionsController < Api::BaseController + include Redisable + include Lockable + + before_action -> { doorkeeper_authorize! :push } + before_action :require_user! + before_action :set_push_subscription, only: [:show, :update] + before_action :check_push_subscription, only: [:show, :update] + + def show + render json: @push_subscription, serializer: REST::WebPushSubscriptionV2Serializer + end + + def create + with_redis_lock("push_subscription:#{current_user.id}") do + destroy_web_push_subscriptions! + @push_subscription = Web::PushSubscription.create!(web_push_subscription_params) + end + + render json: @push_subscription, serializer: REST::WebPushSubscriptionV2Serializer + end + + def update + @push_subscription.update!(data: data_params) + render json: @push_subscription, serializer: REST::WebPushSubscriptionV2Serializer + end + + def destroy + destroy_web_push_subscriptions! + render_empty + end + + private + + def destroy_web_push_subscriptions! + doorkeeper_token.web_push_subscriptions.destroy_all + end + + def set_push_subscription + @push_subscription = doorkeeper_token.web_push_subscriptions.first + end + + def check_push_subscription + not_found if @push_subscription.nil? + end + + def web_push_subscription_params + { + access_token_id: doorkeeper_token.id, + data: data_params, + endpoint: subscription_params[:endpoint], + key_auth: subscription_params[:keys][:auth], + key_p256dh: subscription_params[:keys][:p256dh], + standard: subscription_params[:standard] || false, + user_id: current_user.id, + } + end + + def subscription_params + params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) + end + + def data_params + return {} if params[:data].blank? + + params.expect(data: [:policy, alerts: Notification::TYPES]) + end +end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index ced68d39fc7..5dcf5394ab6 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -9,12 +9,12 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController def create @push_subscription = ::Web::PushSubscription.create!(web_push_subscription_params) - render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionV1Serializer end def update @push_subscription.update!(data: data_params) - render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionV1Serializer end def destroy diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 5f8921e246a..7aee1a3adff 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -9,7 +9,7 @@ class InitialStateSerializer < ActiveModel::Serializer attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? } - has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer + has_one :push_subscription, serializer: REST::WebPushSubscriptionV1Serializer has_one :role, serializer: REST::RoleSerializer def meta diff --git a/app/serializers/rest/web_push_subscription_serializer.rb b/app/serializers/rest/web_push_subscription_v1_serializer.rb similarity index 84% rename from app/serializers/rest/web_push_subscription_serializer.rb rename to app/serializers/rest/web_push_subscription_v1_serializer.rb index 11893f7c487..03bfcb4c618 100644 --- a/app/serializers/rest/web_push_subscription_serializer.rb +++ b/app/serializers/rest/web_push_subscription_v1_serializer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer +class REST::WebPushSubscriptionV1Serializer < ActiveModel::Serializer attributes :id, :endpoint, :standard, :alerts, :server_key, :policy delegate :standard, to: :object diff --git a/app/serializers/rest/web_push_subscription_v2_serializer.rb b/app/serializers/rest/web_push_subscription_v2_serializer.rb new file mode 100644 index 00000000000..76a50684a98 --- /dev/null +++ b/app/serializers/rest/web_push_subscription_v2_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class REST::WebPushSubscriptionV2Serializer < ActiveModel::Serializer + attributes :id, :endpoint, :standard, :alerts, :server_key, :policy + + delegate :standard, to: :object + + def id + object.id.to_s + end + + def alerts + (object.data&.dig('alerts') || {}).transform_values { |v| ActiveModel::Type::Boolean.new.cast(v) } + end + + def server_key + Rails.configuration.x.vapid.public_key + end + + def policy + object.data&.dig('policy') || 'all' + end +end diff --git a/config/routes/api.rb b/config/routes/api.rb index 34b2e255da6..1c2ebcc72b2 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -344,6 +344,10 @@ namespace :api, format: false do resources :statuses, only: [:show, :destroy] end + namespace :push do + resource :subscription, only: [:create, :show, :update, :destroy] + end + namespace :admin do resources :accounts, only: [:index] end diff --git a/spec/requests/api/v2/push/subscription_spec.rb b/spec/requests/api/v2/push/subscription_spec.rb new file mode 100644 index 00000000000..a93b0c9b701 --- /dev/null +++ b/spec/requests/api/v2/push/subscription_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V2 Push Subscriptions' do + let(:user) { Fabricate(:user) } + let(:endpoint) { 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX' } + let(:keys) do + { + p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=', + auth: 'eH_C8rq2raXqlcBVDa1gLg==', + } + end + let(:create_payload) do + { + subscription: { + endpoint: endpoint, + keys: keys, + standard: standard, + }, + }.with_indifferent_access + end + let(:alerts_payload) do + { + data: { + policy: 'all', + + alerts: { + follow: true, + follow_request: true, + favourite: false, + reblog: true, + mention: false, + poll: true, + status: false, + }, + }, + }.with_indifferent_access + end + let(:standard) { '1' } + let(:scopes) { 'push' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'validation error' do + it 'returns a validation error' do + subject + + expect(response).to have_http_status(422) + expect(response.content_type) + .to start_with('application/json') + expect(endpoint_push_subscriptions.count).to eq(0) + expect(endpoint_push_subscription).to be_nil + end + end + + describe 'GET /api/v2/push/subscription' do + subject { get api_v2_push_subscription_path, headers: headers } + + context 'with a subscription' do + let!(:subscription) { create_subscription_with_token } + + before { subscription } + + it 'shows subscription details' do + subject + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + expect(response.parsed_body) + .to include(endpoint: endpoint) + end + + it 'returns subscription.id as a string' do + subject + + expect(response.parsed_body) + .to include(id: subscription.id.to_s) + end + end + + context 'without a subscription' do + it 'returns not found' do + subject + + expect(response) + .to have_http_status(404) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + def create_subscription_with_token + Fabricate( + :web_push_subscription, + endpoint: create_payload[:subscription][:endpoint], + access_token: token, + user: user + ) + end +end