From 97b9994743576f1b6ca93f60b51d447c48c92989 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Fri, 28 Mar 2025 13:16:40 +0100 Subject: [PATCH 1/2] Basic FASP support (#34031) --- Gemfile | 1 + Gemfile.lock | 9 + .../admin/fasp/debug/callbacks_controller.rb | 20 ++ .../admin/fasp/debug_calls_controller.rb | 19 ++ .../admin/fasp/providers_controller.rb | 47 ++++ .../admin/fasp/registrations_controller.rb | 23 ++ app/controllers/api/fasp/base_controller.rb | 81 +++++++ .../debug/v0/callback/responses_controller.rb | 15 ++ .../api/fasp/registrations_controller.rb | 26 +++ .../400-24px/extension-fill.svg | 1 + .../material-icons/400-24px/extension.svg | 1 + app/lib/fasp/request.rb | 76 +++++++ .../concerns/fasp/provider/debug_concern.rb | 10 + app/models/fasp.rb | 7 + app/models/fasp/capability.rb | 10 + app/models/fasp/debug_callback.rb | 16 ++ app/models/fasp/provider.rb | 141 ++++++++++++ app/policies/admin/fasp/provider_policy.rb | 23 ++ .../fasp/debug/callbacks/_callback.html.haml | 10 + .../fasp/debug/callbacks/index.html.haml | 22 ++ .../admin/fasp/providers/_provider.html.haml | 19 ++ app/views/admin/fasp/providers/edit.html.haml | 16 ++ .../admin/fasp/providers/index.html.haml | 20 ++ .../admin/fasp/registrations/new.html.haml | 19 ++ app/views/admin/fasp/shared/_links.html.haml | 5 + config/application.rb | 2 +- config/locales/en.yml | 30 +++ config/navigation.rb | 1 + config/routes.rb | 2 + config/routes/fasp.rb | 29 +++ .../20241205103523_create_fasp_providers.rb | 21 ++ ...41206131513_create_fasp_debug_callbacks.rb | 13 ++ db/schema.rb | 27 +++ .../fasp/debug_callback_fabricator.rb | 7 + spec/fabricators/fasp/provider_fabricator.rb | 31 +++ spec/lib/fasp/request_spec.rb | 57 +++++ spec/models/fasp/provider_spec.rb | 209 ++++++++++++++++++ .../admin/fasp/provider_policy_spec.rb | 34 +++ .../fasp/debug/v0/callback/responses_spec.rb | 28 +++ spec/requests/api/fasp/registrations_spec.rb | 42 ++++ spec/support/fasp/provider_request_helper.rb | 72 ++++++ .../system/admin/fasp/debug/callbacks_spec.rb | 29 +++ spec/system/admin/fasp/debug_calls_spec.rb | 33 +++ spec/system/admin/fasp/providers_spec.rb | 81 +++++++ spec/system/admin/fasp/registrations_spec.rb | 39 ++++ 45 files changed, 1423 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/fasp/debug/callbacks_controller.rb create mode 100644 app/controllers/admin/fasp/debug_calls_controller.rb create mode 100644 app/controllers/admin/fasp/providers_controller.rb create mode 100644 app/controllers/admin/fasp/registrations_controller.rb create mode 100644 app/controllers/api/fasp/base_controller.rb create mode 100644 app/controllers/api/fasp/debug/v0/callback/responses_controller.rb create mode 100644 app/controllers/api/fasp/registrations_controller.rb create mode 100644 app/javascript/material-icons/400-24px/extension-fill.svg create mode 100644 app/javascript/material-icons/400-24px/extension.svg create mode 100644 app/lib/fasp/request.rb create mode 100644 app/models/concerns/fasp/provider/debug_concern.rb create mode 100644 app/models/fasp.rb create mode 100644 app/models/fasp/capability.rb create mode 100644 app/models/fasp/debug_callback.rb create mode 100644 app/models/fasp/provider.rb create mode 100644 app/policies/admin/fasp/provider_policy.rb create mode 100644 app/views/admin/fasp/debug/callbacks/_callback.html.haml create mode 100644 app/views/admin/fasp/debug/callbacks/index.html.haml create mode 100644 app/views/admin/fasp/providers/_provider.html.haml create mode 100644 app/views/admin/fasp/providers/edit.html.haml create mode 100644 app/views/admin/fasp/providers/index.html.haml create mode 100644 app/views/admin/fasp/registrations/new.html.haml create mode 100644 app/views/admin/fasp/shared/_links.html.haml create mode 100644 config/routes/fasp.rb create mode 100644 db/migrate/20241205103523_create_fasp_providers.rb create mode 100644 db/migrate/20241206131513_create_fasp_debug_callbacks.rb create mode 100644 spec/fabricators/fasp/debug_callback_fabricator.rb create mode 100644 spec/fabricators/fasp/provider_fabricator.rb create mode 100644 spec/lib/fasp/request_spec.rb create mode 100644 spec/models/fasp/provider_spec.rb create mode 100644 spec/policies/admin/fasp/provider_policy_spec.rb create mode 100644 spec/requests/api/fasp/debug/v0/callback/responses_spec.rb create mode 100644 spec/requests/api/fasp/registrations_spec.rb create mode 100644 spec/support/fasp/provider_request_helper.rb create mode 100644 spec/system/admin/fasp/debug/callbacks_spec.rb create mode 100644 spec/system/admin/fasp/debug_calls_spec.rb create mode 100644 spec/system/admin/fasp/providers_spec.rb create mode 100644 spec/system/admin/fasp/registrations_spec.rb diff --git a/Gemfile b/Gemfile index b64a1dbe91..9e5955e0b8 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,7 @@ gem 'inline_svg' gem 'irb', '~> 1.8' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' +gem 'linzer', '~> 0.6.1' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar' gem 'mutex_m' diff --git a/Gemfile.lock b/Gemfile.lock index 1ad5429d4b..e49854c6bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -395,6 +395,12 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) + linzer (0.6.2) + openssl (~> 3.0, >= 3.0.0) + rack (>= 2.2, < 4.0) + starry (~> 0.2) + stringio (~> 3.1, >= 3.1.2) + uri (~> 1.0, >= 1.0.2) llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) @@ -829,6 +835,8 @@ GEM simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.27) + starry (0.2.0) + base64 stoplight (4.1.1) redlock (~> 1.0) stringio (3.1.5) @@ -980,6 +988,7 @@ DEPENDENCIES letter_opener (~> 1.8) letter_opener_web (~> 3.0) link_header (~> 0.0) + linzer (~> 0.6.1) lograge (~> 0.12) mail (~> 2.8) mario-redis-lock (~> 1.2) diff --git a/app/controllers/admin/fasp/debug/callbacks_controller.rb b/app/controllers/admin/fasp/debug/callbacks_controller.rb new file mode 100644 index 0000000000..28aba5e489 --- /dev/null +++ b/app/controllers/admin/fasp/debug/callbacks_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Admin::Fasp::Debug::CallbacksController < Admin::BaseController + def index + authorize [:admin, :fasp, :provider], :update? + + @callbacks = Fasp::DebugCallback + .includes(:fasp_provider) + .order(created_at: :desc) + end + + def destroy + authorize [:admin, :fasp, :provider], :update? + + callback = Fasp::DebugCallback.find(params[:id]) + callback.destroy + + redirect_to admin_fasp_debug_callbacks_path + end +end diff --git a/app/controllers/admin/fasp/debug_calls_controller.rb b/app/controllers/admin/fasp/debug_calls_controller.rb new file mode 100644 index 0000000000..1e1b6dbf3c --- /dev/null +++ b/app/controllers/admin/fasp/debug_calls_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Admin::Fasp::DebugCallsController < Admin::BaseController + before_action :set_provider + + def create + authorize [:admin, @provider], :update? + + @provider.perform_debug_call + + redirect_to admin_fasp_providers_path + end + + private + + def set_provider + @provider = Fasp::Provider.find(params[:provider_id]) + end +end diff --git a/app/controllers/admin/fasp/providers_controller.rb b/app/controllers/admin/fasp/providers_controller.rb new file mode 100644 index 0000000000..4f1f1271bf --- /dev/null +++ b/app/controllers/admin/fasp/providers_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Admin::Fasp::ProvidersController < Admin::BaseController + before_action :set_provider, only: [:show, :edit, :update, :destroy] + + def index + authorize [:admin, :fasp, :provider], :index? + + @providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc) + end + + def show + authorize [:admin, @provider], :show? + end + + def edit + authorize [:admin, @provider], :update? + end + + def update + authorize [:admin, @provider], :update? + + if @provider.update(provider_params) + redirect_to admin_fasp_providers_path + else + render :edit + end + end + + def destroy + authorize [:admin, @provider], :destroy? + + @provider.destroy + + redirect_to admin_fasp_providers_path + end + + private + + def provider_params + params.expect(fasp_provider: [capabilities_attributes: {}]) + end + + def set_provider + @provider = Fasp::Provider.find(params[:id]) + end +end diff --git a/app/controllers/admin/fasp/registrations_controller.rb b/app/controllers/admin/fasp/registrations_controller.rb new file mode 100644 index 0000000000..52c46c2eb6 --- /dev/null +++ b/app/controllers/admin/fasp/registrations_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Admin::Fasp::RegistrationsController < Admin::BaseController + before_action :set_provider + + def new + authorize [:admin, @provider], :create? + end + + def create + authorize [:admin, @provider], :create? + + @provider.update_info!(confirm: true) + + redirect_to edit_admin_fasp_provider_path(@provider) + end + + private + + def set_provider + @provider = Fasp::Provider.find(params[:provider_id]) + end +end diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb new file mode 100644 index 0000000000..690f7e419a --- /dev/null +++ b/app/controllers/api/fasp/base_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Api::Fasp::BaseController < ApplicationController + class Error < ::StandardError; end + + DIGEST_PATTERN = /sha-256=:(.*?):/ + KEYID_PATTERN = /keyid="(.*?)"/ + + attr_reader :current_provider + + skip_forgery_protection + + before_action :check_fasp_enabled + before_action :require_authentication + after_action :sign_response + + private + + def require_authentication + validate_content_digest! + validate_signature! + rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e + logger.debug("FASP Authentication error: #{e}") + authentication_error + end + + def authentication_error + respond_to do |format| + format.json { head 401 } + end + end + + def validate_content_digest! + content_digest_header = request.headers['content-digest'] + raise Error, 'content-digest missing' if content_digest_header.blank? + + digest_received = content_digest_header.match(DIGEST_PATTERN)[1] + + digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '') + + raise Error, 'content-digest does not match' if digest_received != digest_computed + end + + def validate_signature! + signature_input = request.headers['signature-input']&.encode('UTF-8') + raise Error, 'signature-input is missing' if signature_input.blank? + + keyid = signature_input.match(KEYID_PATTERN)[1] + provider = Fasp::Provider.find(keyid) + linzer_request = Linzer.new_request( + request.method, + request.original_url, + {}, + { + 'content-digest' => request.headers['content-digest'], + 'signature-input' => signature_input, + 'signature' => request.headers['signature'], + } + ) + message = Linzer::Message.new(linzer_request) + key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) + signature = Linzer::Signature.build(message.headers) + Linzer.verify(key, message, signature) + @current_provider = provider + end + + def sign_response + response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:" + + linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] }) + message = Linzer::Message.new(linzer_response) + key = Linzer.new_ed25519_key(current_provider.server_private_key_pem) + signature = Linzer.sign(key, message, %w(@status content-digest)) + + response.headers.merge!(signature.to_h) + end + + def check_fasp_enabled + raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled? + end +end diff --git a/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb new file mode 100644 index 0000000000..794e53f095 --- /dev/null +++ b/app/controllers/api/fasp/debug/v0/callback/responses_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController + def create + Fasp::DebugCallback.create( + fasp_provider: current_provider, + ip: request.remote_ip, + request_body: request.raw_post + ) + + respond_to do |format| + format.json { head 201 } + end + end +end diff --git a/app/controllers/api/fasp/registrations_controller.rb b/app/controllers/api/fasp/registrations_controller.rb new file mode 100644 index 0000000000..fecc992fec --- /dev/null +++ b/app/controllers/api/fasp/registrations_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Api::Fasp::RegistrationsController < Api::Fasp::BaseController + skip_before_action :require_authentication + + def create + @current_provider = Fasp::Provider.create!( + name: params[:name], + base_url: params[:baseUrl], + remote_identifier: params[:serverId], + provider_public_key_base64: params[:publicKey] + ) + + render json: registration_confirmation + end + + private + + def registration_confirmation + { + faspId: current_provider.id.to_s, + publicKey: current_provider.server_public_key_base64, + registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider), + } + end +end diff --git a/app/javascript/material-icons/400-24px/extension-fill.svg b/app/javascript/material-icons/400-24px/extension-fill.svg new file mode 100644 index 0000000000..f6e7de8cce --- /dev/null +++ b/app/javascript/material-icons/400-24px/extension-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/extension.svg b/app/javascript/material-icons/400-24px/extension.svg new file mode 100644 index 0000000000..16909a6307 --- /dev/null +++ b/app/javascript/material-icons/400-24px/extension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/fasp/request.rb b/app/lib/fasp/request.rb new file mode 100644 index 0000000000..f0c589b7a2 --- /dev/null +++ b/app/lib/fasp/request.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Fasp::Request + def initialize(provider) + @provider = provider + end + + def get(path) + perform_request(:get, path) + end + + def post(path, body: nil) + perform_request(:post, path, body:) + end + + def delete(path, body: nil) + perform_request(:delete, path, body:) + end + + private + + def perform_request(verb, path, body: nil) + url = @provider.url(path) + body = body.present? ? body.to_json : '' + headers = request_headers(verb, url, body) + response = HTTP.headers(headers).send(verb, url, body:) + validate!(response) + + response.parse if response.body.present? + end + + def request_headers(verb, url, body = '') + result = { + 'accept' => 'application/json', + 'content-digest' => content_digest(body), + } + result.merge(signature_headers(verb, url, result)) + end + + def content_digest(body) + "sha-256=:#{OpenSSL::Digest.base64digest('sha256', body || '')}:" + end + + def signature_headers(verb, url, headers) + linzer_request = Linzer.new_request(verb, url, {}, headers) + message = Linzer::Message.new(linzer_request) + key = Linzer.new_ed25519_key(@provider.server_private_key_pem, @provider.remote_identifier) + signature = Linzer.sign(key, message, %w(@method @target-uri content-digest)) + Linzer::Signer.send(:populate_parameters, key, {}) + + signature.to_h + end + + def validate!(response) + content_digest_header = response.headers['content-digest'] + raise SignatureVerification::SignatureVerificationError, 'content-digest missing' if content_digest_header.blank? + raise SignatureVerification::SignatureVerificationError, 'content-digest does not match' if content_digest_header != content_digest(response.body) + + signature_input = response.headers['signature-input']&.encode('UTF-8') + raise SignatureVerification::SignatureVerificationError, 'signature-input is missing' if signature_input.blank? + + linzer_response = Linzer.new_response( + response.body, + response.status, + { + 'content-digest' => content_digest_header, + 'signature-input' => signature_input, + 'signature' => response.headers['signature'], + } + ) + message = Linzer::Message.new(linzer_response) + key = Linzer.new_ed25519_public_key(@provider.provider_public_key_pem) + signature = Linzer::Signature.build(message.headers) + Linzer.verify(key, message, signature) + end +end diff --git a/app/models/concerns/fasp/provider/debug_concern.rb b/app/models/concerns/fasp/provider/debug_concern.rb new file mode 100644 index 0000000000..eee046a17f --- /dev/null +++ b/app/models/concerns/fasp/provider/debug_concern.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Fasp::Provider::DebugConcern + extend ActiveSupport::Concern + + def perform_debug_call + Fasp::Request.new(self) + .post('/debug/v0/callback/logs', body: { hello: 'world' }) + end +end diff --git a/app/models/fasp.rb b/app/models/fasp.rb new file mode 100644 index 0000000000..cb33937715 --- /dev/null +++ b/app/models/fasp.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Fasp + def self.table_name_prefix + 'fasp_' + end +end diff --git a/app/models/fasp/capability.rb b/app/models/fasp/capability.rb new file mode 100644 index 0000000000..eb41571e57 --- /dev/null +++ b/app/models/fasp/capability.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Fasp::Capability + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :id, :string + attribute :version, :string + attribute :enabled, :boolean, default: false +end diff --git a/app/models/fasp/debug_callback.rb b/app/models/fasp/debug_callback.rb new file mode 100644 index 0000000000..30f5d1c37d --- /dev/null +++ b/app/models/fasp/debug_callback.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: fasp_debug_callbacks +# +# id :bigint(8) not null, primary key +# ip :string not null +# request_body :text not null +# created_at :datetime not null +# updated_at :datetime not null +# fasp_provider_id :bigint(8) not null +# +class Fasp::DebugCallback < ApplicationRecord + belongs_to :fasp_provider, class_name: 'Fasp::Provider' +end diff --git a/app/models/fasp/provider.rb b/app/models/fasp/provider.rb new file mode 100644 index 0000000000..cd1b3008c7 --- /dev/null +++ b/app/models/fasp/provider.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: fasp_providers +# +# id :bigint(8) not null, primary key +# base_url :string not null +# capabilities :jsonb not null +# confirmed :boolean default(FALSE), not null +# contact_email :string +# fediverse_account :string +# name :string not null +# privacy_policy :jsonb +# provider_public_key_pem :string not null +# remote_identifier :string not null +# server_private_key_pem :string not null +# sign_in_url :string +# created_at :datetime not null +# updated_at :datetime not null +# +class Fasp::Provider < ApplicationRecord + include DebugConcern + + has_many :fasp_debug_callbacks, inverse_of: :fasp_provider, class_name: 'Fasp::DebugCallback', dependent: :delete_all + + validates :name, presence: true + validates :base_url, presence: true, url: true + validates :provider_public_key_pem, presence: true + validates :remote_identifier, presence: true + + before_create :create_keypair + after_commit :update_remote_capabilities + + def capabilities + read_attribute(:capabilities).map do |attributes| + Fasp::Capability.new(attributes) + end + end + + def capabilities_attributes=(attributes) + capability_objects = attributes.values.map { |a| Fasp::Capability.new(a) } + self[:capabilities] = capability_objects.map(&:attributes) + end + + def enabled_capabilities + capabilities.select(&:enabled).map(&:id) + end + + def capability?(capability_name) + return false unless confirmed? + + capabilities.present? && capabilities.any? do |capability| + capability.id == capability_name + end + end + + def capability_enabled?(capability_name) + return false unless confirmed? + + capabilities.present? && capabilities.any? do |capability| + capability.id == capability_name && capability.enabled + end + end + + def server_private_key + @server_private_key ||= OpenSSL::PKey.read(server_private_key_pem) + end + + def server_public_key_base64 + Base64.strict_encode64(server_private_key.raw_public_key) + end + + def provider_public_key_base64=(string) + return if string.blank? + + self.provider_public_key_pem = + OpenSSL::PKey.new_raw_public_key( + 'ed25519', + Base64.strict_decode64(string) + ).public_to_pem + end + + def provider_public_key + @provider_public_key ||= OpenSSL::PKey.read(provider_public_key_pem) + end + + def provider_public_key_raw + provider_public_key.raw_public_key + end + + def provider_public_key_fingerprint + OpenSSL::Digest.base64digest('sha256', provider_public_key_raw) + end + + def url(path) + base = base_url + base = base.chomp('/') if path.start_with?('/') + "#{base}#{path}" + end + + def update_info!(confirm: false) + self.confirmed = true if confirm + provider_info = Fasp::Request.new(self).get('/provider_info') + assign_attributes( + privacy_policy: provider_info['privacyPolicy'], + capabilities: provider_info['capabilities'], + sign_in_url: provider_info['signInUrl'], + contact_email: provider_info['contactEmail'], + fediverse_account: provider_info['fediverseAccount'] + ) + save! + end + + private + + def create_keypair + self.server_private_key_pem ||= + OpenSSL::PKey.generate_key('ed25519').private_to_pem + end + + def update_remote_capabilities + return unless saved_change_to_attribute?(:capabilities) + + old, current = saved_change_to_attribute(:capabilities) + old ||= [] + current.each do |capability| + update_remote_capability(capability) if capability.key?('enabled') && !old.include?(capability) + end + end + + def update_remote_capability(capability) + version, = capability['version'].split('.') + path = "/capabilities/#{capability['id']}/#{version}/activation" + if capability['enabled'] + Fasp::Request.new(self).post(path) + else + Fasp::Request.new(self).delete(path) + end + end +end diff --git a/app/policies/admin/fasp/provider_policy.rb b/app/policies/admin/fasp/provider_policy.rb new file mode 100644 index 0000000000..a8088fd37d --- /dev/null +++ b/app/policies/admin/fasp/provider_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Admin::Fasp::ProviderPolicy < ApplicationPolicy + def index? + role.can?(:manage_federation) + end + + def show? + role.can?(:manage_federation) + end + + def create? + role.can?(:manage_federation) + end + + def update? + role.can?(:manage_federation) + end + + def destroy? + role.can?(:manage_federation) + end +end diff --git a/app/views/admin/fasp/debug/callbacks/_callback.html.haml b/app/views/admin/fasp/debug/callbacks/_callback.html.haml new file mode 100644 index 0000000000..6b6d5cfd04 --- /dev/null +++ b/app/views/admin/fasp/debug/callbacks/_callback.html.haml @@ -0,0 +1,10 @@ +%tr + %td= callback.fasp_provider.name + %td= callback.fasp_provider.base_url + %td= callback.ip + %td + %time.relative-formatted{ datetime: callback.created_at.iso8601 } + %td + %code= callback.request_body + %td + = table_link_to 'close', t('admin.fasp.debug.callbacks.delete'), admin_fasp_debug_callback_path(callback), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/fasp/debug/callbacks/index.html.haml b/app/views/admin/fasp/debug/callbacks/index.html.haml new file mode 100644 index 0000000000..d83ae95fa5 --- /dev/null +++ b/app/views/admin/fasp/debug/callbacks/index.html.haml @@ -0,0 +1,22 @@ +- content_for :page_title do + = t('admin.fasp.debug.callbacks.title') + +- content_for :heading do + %h2= t('admin.fasp.debug.callbacks.title') + = render 'admin/fasp/shared/links' + +- unless @callbacks.empty? + %hr.spacer + + .table-wrapper + %table.table + %thead + %tr + %th= t('admin.fasp.providers.name') + %th= t('admin.fasp.providers.base_url') + %th= t('admin.fasp.debug.callbacks.ip') + %th= t('admin.fasp.debug.callbacks.created_at') + %th= t('admin.fasp.debug.callbacks.request_body') + %th + %tbody + = render partial: 'callback', collection: @callbacks diff --git a/app/views/admin/fasp/providers/_provider.html.haml b/app/views/admin/fasp/providers/_provider.html.haml new file mode 100644 index 0000000000..6184daac7f --- /dev/null +++ b/app/views/admin/fasp/providers/_provider.html.haml @@ -0,0 +1,19 @@ +%tr + %td= provider.name + %td= provider.base_url + %td + - if provider.confirmed? + = t('admin.fasp.providers.active') + - else + = t('admin.fasp.providers.registration_requested') + %td + - if provider.confirmed? + = table_link_to 'edit', t('admin.fasp.providers.edit'), edit_admin_fasp_provider_path(provider) + - else + = table_link_to 'check', t('admin.fasp.providers.finish_registration'), new_admin_fasp_provider_registration_path(provider) + - if provider.sign_in_url.present? + = table_link_to 'open_in_new', t('admin.fasp.providers.sign_in'), provider.sign_in_url, target: '_blank' + - if provider.capability_enabled?('callback') + = table_link_to 'repeat', t('admin.fasp.providers.callback'), admin_fasp_provider_debug_calls_path(provider), data: { method: :post } + + = table_link_to 'close', t('admin.fasp.providers.delete'), admin_fasp_provider_path(provider), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/fasp/providers/edit.html.haml b/app/views/admin/fasp/providers/edit.html.haml new file mode 100644 index 0000000000..f4a799c777 --- /dev/null +++ b/app/views/admin/fasp/providers/edit.html.haml @@ -0,0 +1,16 @@ +- content_for :page_title do + = t('admin.fasp.providers.edit') + += simple_form_for [:admin, @provider] do |f| + = render 'shared/error_messages', object: @provider + + %h4= t('admin.fasp.providers.select_capabilities') + + .fields_group + = f.fields_for :capabilities do |cf| + = cf.input :id, as: :hidden + = cf.input :version, as: :hidden + = cf.input :enabled, as: :boolean, label: cf.object.id, wrapper: :with_label + + .actions + = f.button :button, t('admin.fasp.providers.save'), type: :submit diff --git a/app/views/admin/fasp/providers/index.html.haml b/app/views/admin/fasp/providers/index.html.haml new file mode 100644 index 0000000000..209f7e8034 --- /dev/null +++ b/app/views/admin/fasp/providers/index.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('admin.fasp.providers.title') + +- content_for :heading do + %h2= t('admin.fasp.providers.title') + = render 'admin/fasp/shared/links' + +- unless @providers.empty? + %hr.spacer + + .table-wrapper + %table.table#providers + %thead + %tr + %th= t('admin.fasp.providers.name') + %th= t('admin.fasp.providers.base_url') + %th= t('admin.fasp.providers.status') + %th + %tbody + = render partial: 'provider', collection: @providers diff --git a/app/views/admin/fasp/registrations/new.html.haml b/app/views/admin/fasp/registrations/new.html.haml new file mode 100644 index 0000000000..68eb940c09 --- /dev/null +++ b/app/views/admin/fasp/registrations/new.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('admin.fasp.providers.registrations.title') + +%p= t('admin.fasp.providers.registrations.description') + +%table.table.inline-table + %tbody + %tr + %th= t('admin.fasp.providers.name') + %td= @provider.name + %tr + %th= t('admin.fasp.providers.public_key_fingerprint') + %td + %code= @provider.provider_public_key_fingerprint + += form_with url: admin_fasp_provider_registration_path(@provider), class: :simple_form do |f| + .actions + = link_to t('admin.fasp.providers.registrations.reject'), admin_fasp_provider_path(@provider), data: { method: :delete }, class: 'btn negative' + = f.button t('admin.fasp.providers.registrations.confirm'), type: :submit, class: 'btn' diff --git a/app/views/admin/fasp/shared/_links.html.haml b/app/views/admin/fasp/shared/_links.html.haml new file mode 100644 index 0000000000..0c1d1eb4db --- /dev/null +++ b/app/views/admin/fasp/shared/_links.html.haml @@ -0,0 +1,5 @@ +.content__heading__tabs + = render_navigation renderer: :links do |primary| + :ruby + primary.item :providers, safe_join([material_symbol('database'), t('admin.fasp.providers.providers')]), admin_fasp_providers_path + primary.item :debug_callbacks, safe_join([material_symbol('repeat'), t('admin.fasp.debug.callbacks.title')]), admin_fasp_debug_callbacks_path diff --git a/config/application.rb b/config/application.rb index e1af98b448..ae960f8b24 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,8 +36,8 @@ require_relative '../lib/paperclip/response_with_limit_adapter' require_relative '../lib/terrapin/multi_pipe_extensions' require_relative '../lib/mastodon/middleware/public_file_server' require_relative '../lib/mastodon/middleware/socket_cleanup' -require_relative '../lib/mastodon/snowflake' require_relative '../lib/mastodon/feature' +require_relative '../lib/mastodon/snowflake' require_relative '../lib/mastodon/version' require_relative '../lib/devise/strategies/two_factor_ldap_authenticatable' require_relative '../lib/devise/strategies/two_factor_pam_authenticatable' diff --git a/config/locales/en.yml b/config/locales/en.yml index 4302d1f536..4c5e1466f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -479,6 +479,36 @@ en: new: title: Import domain blocks no_file: No file selected + fasp: + debug: + callbacks: + created_at: Created at + delete: Delete + ip: IP address + request_body: Request body + title: Debug Callbacks + providers: + active: Active + base_url: Base URL + callback: Callback + delete: Delete + edit: Edit Provider + finish_registration: Finish registration + name: Name + providers: Providers + public_key_fingerprint: Public key fingerprint + registration_requested: Registration requested + registrations: + confirm: Confirm + description: You received a registration from a FASP. Reject it if you did not initiate this. If you initiated this, carefully compare name and key fingerprint before confirming the registration. + reject: Reject + title: Confirm FASP Registration + save: Save + select_capabilities: Select Capabilities + sign_in: Sign In + status: Status + title: Fediverse Auxiliary Service Providers + title: FASP follow_recommendations: description_html: "Follow recommendations help new users quickly find interesting content. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language." language: For language diff --git a/config/navigation.rb b/config/navigation.rb index 225106592c..d60f8cbc5b 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -73,6 +73,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :announcements, safe_join([material_symbol('campaign'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) } s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) } s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) } + s.item :fasp, safe_join([material_symbol('extension'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) } if Mastodon::Feature.fasp_enabled? s.item :relays, safe_join([material_symbol('captive_portal'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) } end diff --git a/config/routes.rb b/config/routes.rb index e31fbcb06d..5b130c517b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,6 +196,8 @@ Rails.application.routes.draw do draw(:api) + draw(:fasp) + draw(:web_app) get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false diff --git a/config/routes/fasp.rb b/config/routes/fasp.rb new file mode 100644 index 0000000000..9d052526de --- /dev/null +++ b/config/routes/fasp.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +namespace :api, format: false do + namespace :fasp do + namespace :debug do + namespace :v0 do + namespace :callback do + resources :responses, only: [:create] + end + end + end + + resource :registration, only: [:create] + end +end + +namespace :admin do + namespace :fasp do + namespace :debug do + resources :callbacks, only: [:index, :destroy] + end + + resources :providers, only: [:index, :show, :edit, :update, :destroy] do + resources :debug_calls, only: [:create] + + resource :registration, only: [:new, :create] + end + end +end diff --git a/db/migrate/20241205103523_create_fasp_providers.rb b/db/migrate/20241205103523_create_fasp_providers.rb new file mode 100644 index 0000000000..ac1d52e8a7 --- /dev/null +++ b/db/migrate/20241205103523_create_fasp_providers.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateFaspProviders < ActiveRecord::Migration[7.2] + def change + create_table :fasp_providers do |t| + t.boolean :confirmed, null: false, default: false + t.string :name, null: false + t.string :base_url, null: false, index: { unique: true } + t.string :sign_in_url + t.string :remote_identifier, null: false + t.string :provider_public_key_pem, null: false + t.string :server_private_key_pem, null: false + t.jsonb :capabilities, null: false, default: [] + t.jsonb :privacy_policy + t.string :contact_email + t.string :fediverse_account + + t.timestamps + end + end +end diff --git a/db/migrate/20241206131513_create_fasp_debug_callbacks.rb b/db/migrate/20241206131513_create_fasp_debug_callbacks.rb new file mode 100644 index 0000000000..6b221ce93f --- /dev/null +++ b/db/migrate/20241206131513_create_fasp_debug_callbacks.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateFaspDebugCallbacks < ActiveRecord::Migration[7.2] + def change + create_table :fasp_debug_callbacks do |t| + t.references :fasp_provider, null: false, foreign_key: true + t.string :ip, null: false + t.text :request_body, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 32d94b48ec..26db259464 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -445,6 +445,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true end + create_table "fasp_debug_callbacks", force: :cascade do |t| + t.bigint "fasp_provider_id", null: false + t.string "ip", null: false + t.text "request_body", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id" + end + + create_table "fasp_providers", force: :cascade do |t| + t.boolean "confirmed", default: false, null: false + t.string "name", null: false + t.string "base_url", null: false + t.string "sign_in_url" + t.string "remote_identifier", null: false + t.string "provider_public_key_pem", null: false + t.string "server_private_key_pem", null: false + t.jsonb "capabilities", default: [], null: false + t.jsonb "privacy_policy" + t.string "contact_email" + t.string "fediverse_account" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true + end + create_table "favourites", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -1289,6 +1315,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade + add_foreign_key "fasp_debug_callbacks", "fasp_providers" add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade add_foreign_key "featured_tags", "accounts", on_delete: :cascade diff --git a/spec/fabricators/fasp/debug_callback_fabricator.rb b/spec/fabricators/fasp/debug_callback_fabricator.rb new file mode 100644 index 0000000000..28c1c00be8 --- /dev/null +++ b/spec/fabricators/fasp/debug_callback_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:fasp_debug_callback, from: 'Fasp::DebugCallback') do + fasp_provider + ip '127.0.0.234' + request_body 'MyText' +end diff --git a/spec/fabricators/fasp/provider_fabricator.rb b/spec/fabricators/fasp/provider_fabricator.rb new file mode 100644 index 0000000000..fd7867402a --- /dev/null +++ b/spec/fabricators/fasp/provider_fabricator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +Fabricator(:fasp_provider, from: 'Fasp::Provider') do + name { Faker::App.name } + base_url { Faker::Internet.unique.url } + sign_in_url { Faker::Internet.url } + remote_identifier 'MyString' + provider_public_key_pem "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAh2ldXsaej2MXj0DHdCx7XibSo66uKlrLfJ5J6hte1Gk=\n-----END PUBLIC KEY-----\n" + server_private_key_pem "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEICDjlajhVb8XfzyTchQWKraMKwtQW+r4opoAg7V3kw1Q\n-----END PRIVATE KEY-----\n" + capabilities [] +end + +Fabricator(:confirmed_fasp, from: :fasp_provider) do + confirmed true + capabilities [ + { id: 'callback', version: '0.1' }, + { id: 'data_sharing', version: '0.1' }, + ] +end + +Fabricator(:debug_fasp, from: :fasp_provider) do + confirmed true + capabilities [ + { id: 'callback', version: '0.1', enabled: true }, + ] + + after_build do |fasp| + # Prevent fabrication from attempting an HTTP call to the provider + def fasp.update_remote_capabilities = true + end +end diff --git a/spec/lib/fasp/request_spec.rb b/spec/lib/fasp/request_spec.rb new file mode 100644 index 0000000000..ab1265e14f --- /dev/null +++ b/spec/lib/fasp/request_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'securerandom' + +RSpec.describe Fasp::Request do + include ProviderRequestHelper + + subject { described_class.new(provider) } + + let(:provider) do + Fabricate(:fasp_provider, base_url: 'https://reqprov.example.com/fasp') + end + + shared_examples 'a provider request' do |method| + context 'when the response is signed by the provider' do + before do + stub_provider_request(provider, method:, path: '/test_path') + end + + it "performs a signed #{method.to_s.upcase} request relative to the base_path of the fasp" do + subject.send(method, '/test_path') + + expect(WebMock).to have_requested(method, 'https://reqprov.example.com/fasp/test_path') + .with(headers: { + 'Signature' => /.+/, + 'Signature-Input' => /.+/, + }) + end + end + + context 'when the response is not signed' do + before do + stub_request(method, 'https://reqprov.example.com/fasp/test_path') + .to_return(status: 200) + end + + it 'raises an error' do + expect do + subject.send(method, '/test_path') + end.to raise_error(SignatureVerification::SignatureVerificationError) + end + end + end + + describe '#get' do + include_examples 'a provider request', :get + end + + describe '#post' do + include_examples 'a provider request', :post + end + + describe '#delete' do + include_examples 'a provider request', :delete + end +end diff --git a/spec/models/fasp/provider_spec.rb b/spec/models/fasp/provider_spec.rb new file mode 100644 index 0000000000..52df4638fd --- /dev/null +++ b/spec/models/fasp/provider_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fasp::Provider do + include ProviderRequestHelper + + describe '#capabilities' do + subject { described_class.new(confirmed: true, capabilities:) } + + let(:capabilities) do + [ + { 'id' => 'one', 'enabled' => false }, + { 'id' => 'two' }, + ] + end + + it 'returns an array of `Fasp::Capability` objects' do + expect(subject.capabilities).to all(be_a(Fasp::Capability)) + end + end + + describe '#capabilities_attributes=' do + subject { described_class.new(confirmed: true) } + + let(:capabilities_params) do + { + '0' => { 'id' => 'one', 'enabled' => '1' }, + '1' => { 'id' => 'two', 'enabled' => '0' }, + '2' => { 'id' => 'three' }, + } + end + + it 'sets capabilities from nested form style hash' do + subject.capabilities_attributes = capabilities_params + + expect(subject).to be_capability('one') + expect(subject).to be_capability('two') + expect(subject).to be_capability('three') + expect(subject).to be_capability_enabled('one') + expect(subject).to_not be_capability_enabled('two') + expect(subject).to_not be_capability_enabled('three') + end + end + + describe '#capability?' do + subject { described_class.new(confirmed:, capabilities:) } + + let(:capabilities) do + [ + { 'id' => 'one', 'enabled' => false }, + { 'id' => 'two', 'enabled' => true }, + ] + end + + context 'when the provider is not confirmed' do + let(:confirmed) { false } + + it 'always returns false' do + expect(subject.capability?('one')).to be false + expect(subject.capability?('two')).to be false + end + end + + context 'when the provider is confirmed' do + let(:confirmed) { true } + + it 'returns true for available and false for missing capabilities' do + expect(subject.capability?('one')).to be true + expect(subject.capability?('two')).to be true + expect(subject.capability?('three')).to be false + end + end + end + + describe '#capability_enabled?' do + subject { described_class.new(confirmed:, capabilities:) } + + let(:capabilities) do + [ + { 'id' => 'one', 'enabled' => false }, + { 'id' => 'two', 'enabled' => true }, + ] + end + + context 'when the provider is not confirmed' do + let(:confirmed) { false } + + it 'always returns false' do + expect(subject).to_not be_capability_enabled('one') + expect(subject).to_not be_capability_enabled('two') + end + end + + context 'when the provider is confirmed' do + let(:confirmed) { true } + + it 'returns true for enabled and false for disabled or missing capabilities' do + expect(subject).to_not be_capability_enabled('one') + expect(subject).to be_capability_enabled('two') + expect(subject).to_not be_capability_enabled('three') + end + end + end + + describe '#server_private_key' do + subject { Fabricate(:fasp_provider) } + + it 'returns an OpenSSL::PKey::PKey' do + expect(subject.server_private_key).to be_a OpenSSL::PKey::PKey + end + end + + describe '#server_public_key_base64' do + subject { Fabricate(:fasp_provider) } + + it 'returns the server public key base64 encoded' do + expect(subject.server_public_key_base64).to eq 'T2RHkakkqAOWEMRYv9OY7LGsuIcAdmBlxuXOKax6sjw=' + end + end + + describe '#provider_public_key_base64=' do + subject { Fabricate(:fasp_provider) } + + it 'allows setting the provider public key from a base64 encoded raw key' do + subject.provider_public_key_base64 = '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=' + + expect(subject.provider_public_key_pem).to eq "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=\n-----END PUBLIC KEY-----\n" + end + end + + describe '#provider_public_key' do + subject { Fabricate(:fasp_provider) } + + it 'returns an OpenSSL::PKey::PKey' do + expect(subject.provider_public_key).to be_a OpenSSL::PKey::PKey + end + end + + describe '#provider_public_key_raw' do + subject { Fabricate(:fasp_provider) } + + it 'returns a string comprised of raw bytes' do + expect(subject.provider_public_key_raw).to be_a String + expect(subject.provider_public_key_raw.encoding).to eq Encoding::BINARY + end + end + + describe '#provider_public_key_fingerprint' do + subject { Fabricate(:fasp_provider) } + + it 'returns a base64 encoded sha256 hash of the public key' do + expect(subject.provider_public_key_fingerprint).to eq '/AmW9EMlVq4o+Qcu9lNfTE8Ss/v9+evMPtyj2R437qE=' + end + end + + describe '#url' do + subject { Fabricate(:fasp_provider, base_url: 'https://myprovider.example.com/fasp_base/') } + + it 'returns a full URL for a given path' do + url = subject.url('/test_path') + expect(url).to eq 'https://myprovider.example.com/fasp_base/test_path' + end + end + + describe '#update_info!' do + subject { Fabricate(:fasp_provider, base_url: 'https://myprov.example.com/fasp/') } + + before do + stub_provider_request(subject, + path: '/provider_info', + response_body: { + capabilities: [ + { id: 'debug', version: '0.1' }, + ], + contactEmail: 'newcontact@example.com', + fediverseAccount: '@newfedi@social.example.com', + privacyPolicy: 'https::///example.com/privacy', + signInUrl: 'https://myprov.example.com/sign_in', + }) + end + + context 'when setting confirm to `true`' do + it 'updates the provider and marks it as `confirmed`' do + subject.update_info!(confirm: true) + + expect(subject.contact_email).to eq 'newcontact@example.com' + expect(subject.fediverse_account).to eq '@newfedi@social.example.com' + expect(subject.privacy_policy).to eq 'https::///example.com/privacy' + expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in' + expect(subject).to be_confirmed + expect(subject).to be_persisted + end + end + + context 'when setting confirm to `false`' do + it 'updates the provider but does not mark it as `confirmed`' do + subject.update_info! + + expect(subject.contact_email).to eq 'newcontact@example.com' + expect(subject.fediverse_account).to eq '@newfedi@social.example.com' + expect(subject.privacy_policy).to eq 'https::///example.com/privacy' + expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in' + expect(subject).to_not be_confirmed + expect(subject).to be_persisted + end + end + end +end diff --git a/spec/policies/admin/fasp/provider_policy_spec.rb b/spec/policies/admin/fasp/provider_policy_spec.rb new file mode 100644 index 0000000000..802760f2e9 --- /dev/null +++ b/spec/policies/admin/fasp/provider_policy_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Fasp::ProviderPolicy, type: :policy do + subject { described_class } + + let(:admin) { Fabricate(:admin_user).account } + let(:user) { Fabricate(:account) } + + shared_examples 'admin only' do |target| + let(:provider) { target.is_a?(Symbol) ? Fabricate(target) : target } + + context 'with an admin' do + it 'permits' do + expect(subject).to permit(admin, provider) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(subject).to_not permit(user, provider) + end + end + end + + permissions :index?, :create? do + include_examples 'admin only', Fasp::Provider + end + + permissions :show?, :create?, :update?, :destroy? do + include_examples 'admin only', :fasp_provider + end +end diff --git a/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb new file mode 100644 index 0000000000..58c5e8897b --- /dev/null +++ b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses', feature: :fasp do + include ProviderRequestHelper + + describe 'POST /api/fasp/debug/v0/callback/responses' do + let(:provider) { Fabricate(:debug_fasp) } + + it 'create a record of the callback' do + payload = { test: 'call' } + headers = request_authentication_headers(provider, + url: api_fasp_debug_v0_callback_responses_url, + method: :post, + body: payload) + + expect do + post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json + end.to change(Fasp::DebugCallback, :count).by(1) + expect(response).to have_http_status(201) + + debug_callback = Fasp::DebugCallback.last + expect(debug_callback.fasp_provider).to eq provider + expect(debug_callback.request_body).to eq '{"test":"call"}' + end + end +end diff --git a/spec/requests/api/fasp/registrations_spec.rb b/spec/requests/api/fasp/registrations_spec.rb new file mode 100644 index 0000000000..53fdfeef5c --- /dev/null +++ b/spec/requests/api/fasp/registrations_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::Fasp::Registrations', feature: :fasp do + describe 'POST /api/fasp/registration' do + subject do + post api_fasp_registration_path, params: + end + + context 'when given valid data' do + let(:params) do + { + name: 'Test Provider', + baseUrl: 'https://newprovider.example.com/fasp', + serverId: '123', + publicKey: '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=', + } + end + + it 'creates a new provider' do + expect { subject }.to change(Fasp::Provider, :count).by(1) + + expect(response).to have_http_status 200 + end + end + + context 'when given invalid data' do + let(:params) do + { + name: 'incomplete', + } + end + + it 'does not create a provider and returns an error code' do + expect { subject }.to_not change(Fasp::Provider, :count) + + expect(response).to have_http_status 422 + end + end + end +end diff --git a/spec/support/fasp/provider_request_helper.rb b/spec/support/fasp/provider_request_helper.rb new file mode 100644 index 0000000000..c5d8ae4919 --- /dev/null +++ b/spec/support/fasp/provider_request_helper.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module ProviderRequestHelper + private + + def stub_provider_request(provider, path: '/', method: :get, response_status: 200, response_body: '') + response_body = encode_body(response_body) + response_headers = { + 'content-type' => 'application/json', + }.merge(response_authentication_headers(provider, response_status, response_body)) + + stub_request(method, provider.url(path)) + .to_return do |_request| + { + status: response_status, + body: response_body, + headers: response_headers, + } + end + end + + def request_authentication_headers(provider, url: root_url, method: :get, body: '') + body = encode_body(body) + headers = {} + headers['content-digest'] = content_digest(body) + request = Linzer.new_request(method, url, {}, headers) + key = private_key_for(provider) + signature = sign(request, key, %w(@method @target-uri content-digest)) + headers.merge(signature.to_h) + end + + def response_authentication_headers(provider, status, body) + headers = {} + headers['content-digest'] = content_digest(body) + response = Linzer.new_response(body, status, headers) + key = private_key_for(provider) + signature = sign(response, key, %w(@status content-digest)) + headers.merge(signature.to_h) + end + + def private_key_for(provider) + @cached_provider_keys ||= {} + @cached_provider_keys[provider] ||= + begin + key = OpenSSL::PKey.generate_key('ed25519') + provider.update!(provider_public_key_pem: key.public_to_pem) + key + end + + { + id: provider.id.to_s, + private_key: @cached_provider_keys[provider].private_to_pem, + } + end + + def sign(request_or_response, key, components) + message = Linzer::Message.new(request_or_response) + linzer_key = Linzer.new_ed25519_key(key[:private_key], key[:id]) + Linzer.sign(linzer_key, message, components) + end + + def encode_body(body) + return body if body.nil? || body.is_a?(String) + + encoder = ActionDispatch::RequestEncoder.encoder(:json) + encoder.encode_params(body) + end + + def content_digest(content) + "sha-256=:#{OpenSSL::Digest.base64digest('sha256', content)}:" + end +end diff --git a/spec/system/admin/fasp/debug/callbacks_spec.rb b/spec/system/admin/fasp/debug/callbacks_spec.rb new file mode 100644 index 0000000000..0e47aac677 --- /dev/null +++ b/spec/system/admin/fasp/debug/callbacks_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Debug FASP Callback Management', feature: :fasp do + before { sign_in Fabricate(:admin_user) } + + describe 'Viewing and deleting callbacks' do + let(:provider) { Fabricate(:fasp_provider, name: 'debug prov') } + + before do + Fabricate(:fasp_debug_callback, fasp_provider: provider, request_body: 'called back') + end + + it 'displays callbacks and allows to delete them' do + visit admin_fasp_debug_callbacks_path + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title')) + expect(page).to have_css('td', text: 'debug prov') + expect(page).to have_css('code', text: 'called back') + + expect do + click_on I18n.t('admin.fasp.debug.callbacks.delete') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title')) + end.to change(Fasp::DebugCallback, :count).by(-1) + end + end +end diff --git a/spec/system/admin/fasp/debug_calls_spec.rb b/spec/system/admin/fasp/debug_calls_spec.rb new file mode 100644 index 0000000000..d2f6a3a08b --- /dev/null +++ b/spec/system/admin/fasp/debug_calls_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FASP Debug Calls', feature: :fasp do + include ProviderRequestHelper + + before { sign_in Fabricate(:admin_user) } + + describe 'Triggering a FASP debug call' do + let!(:provider) { Fabricate(:debug_fasp) } + let!(:debug_call) do + stub_provider_request(provider, + method: :post, + path: '/debug/v0/callback/logs', + response_status: 201) + end + + it 'makes a debug call to the provider' do + visit admin_fasp_providers_path + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(page).to have_css('td', text: provider.name) + + within 'table#providers' do + click_on I18n.t('admin.fasp.providers.callback') + end + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(debug_call).to have_been_requested + end + end +end diff --git a/spec/system/admin/fasp/providers_spec.rb b/spec/system/admin/fasp/providers_spec.rb new file mode 100644 index 0000000000..03837ad5d9 --- /dev/null +++ b/spec/system/admin/fasp/providers_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FASP Management', feature: :fasp do + include ProviderRequestHelper + + before { sign_in Fabricate(:admin_user) } + + describe 'Managing capabilities' do + let!(:provider) { Fabricate(:confirmed_fasp) } + let!(:enable_call) do + stub_provider_request(provider, + method: :post, + path: '/capabilities/callback/0/activation') + end + let!(:disable_call) do + stub_provider_request(provider, + method: :delete, + path: '/capabilities/callback/0/activation') + end + + before do + # We currently err on the side of caution and prefer to send + # a "disable capability" call too often over risking to miss + # one. So the following call _can_ happen here, and if it does + # that is fine, but it has no bearing on the behavior that is + # being tested. + stub_provider_request(provider, + method: :delete, + path: '/capabilities/data_sharing/0/activation') + end + + it 'allows enabling and disabling of capabilities' do + visit admin_fasp_providers_path + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(page).to have_css('td', text: provider.name) + + click_on I18n.t('admin.fasp.providers.edit') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit')) + + check 'callback' + + click_on I18n.t('admin.fasp.providers.save') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(provider.reload).to be_capability_enabled('callback') + expect(enable_call).to have_been_requested + + click_on I18n.t('admin.fasp.providers.edit') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit')) + + uncheck 'callback' + + click_on I18n.t('admin.fasp.providers.save') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(provider.reload).to_not be_capability_enabled('callback') + expect(disable_call).to have_been_requested + end + end + + describe 'Removing a provider' do + let!(:provider) { Fabricate(:fasp_provider) } + + it 'allows to completely remove a provider' do + visit admin_fasp_providers_path + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(page).to have_css('td', text: provider.name) + + click_on I18n.t('admin.fasp.providers.delete') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title')) + expect(page).to have_no_css('td', text: provider.name) + end + end +end diff --git a/spec/system/admin/fasp/registrations_spec.rb b/spec/system/admin/fasp/registrations_spec.rb new file mode 100644 index 0000000000..3da6f01915 --- /dev/null +++ b/spec/system/admin/fasp/registrations_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FASP registration', feature: :fasp do + include ProviderRequestHelper + + before { sign_in Fabricate(:admin_user) } + + describe 'Confirming an unconfirmed FASP' do + let(:provider) { Fabricate(:fasp_provider, confirmed: false) } + + before do + stub_provider_request(provider, + path: '/provider_info', + response_body: { + capabilities: [ + { id: 'debug', version: '0.1' }, + ], + contactEmail: 'newcontact@example.com', + fediverseAccount: '@newfedi@social.example.com', + privacyPolicy: 'https::///example.com/privacy', + signInUrl: 'https://myprov.example.com/sign_in', + }) + end + + it 'displays key fingerprint and updates the provider on confirmation' do + visit new_admin_fasp_provider_registration_path(provider) + + expect(page).to have_css('code', text: provider.provider_public_key_fingerprint) + + click_on I18n.t('admin.fasp.providers.registrations.confirm') + + expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit')) + + expect(provider.reload).to be_confirmed + end + end +end From 902aab1245a06319b7acd20045bd9ce051627300 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 28 Mar 2025 13:34:51 +0100 Subject: [PATCH 2/2] Remove react-motion library (#34293) --- .../mastodon/components/animated_number.tsx | 78 ++-- app/javascript/mastodon/components/poll.jsx | 248 ------------ app/javascript/mastodon/components/poll.tsx | 352 ++++++++++++++++++ .../mastodon/components/status_content.jsx | 4 +- .../mastodon/containers/media_container.jsx | 2 +- .../mastodon/containers/poll_container.js | 38 -- .../compose/components/compose_form.jsx | 4 +- .../compose/components/upload_progress.jsx | 48 --- .../compose/components/upload_progress.tsx | 61 +++ .../features/compose/components/warning.jsx | 28 -- .../features/compose/components/warning.tsx | 96 +++++ .../compose/containers/warning_container.jsx | 46 --- .../components/announcements.jsx | 119 +++--- .../features/ui/components/upload_area.jsx | 55 --- .../features/ui/components/upload_area.tsx | 78 ++++ app/javascript/mastodon/features/ui/index.jsx | 2 +- .../features/ui/util/optional_motion.js | 7 - .../features/ui/util/reduced_motion.jsx | 45 --- package.json | 2 - yarn.lock | 49 +-- 20 files changed, 692 insertions(+), 670 deletions(-) delete mode 100644 app/javascript/mastodon/components/poll.jsx create mode 100644 app/javascript/mastodon/components/poll.tsx delete mode 100644 app/javascript/mastodon/containers/poll_container.js delete mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.jsx create mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.tsx delete mode 100644 app/javascript/mastodon/features/compose/components/warning.jsx create mode 100644 app/javascript/mastodon/features/compose/components/warning.tsx delete mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.jsx delete mode 100644 app/javascript/mastodon/features/ui/components/upload_area.jsx create mode 100644 app/javascript/mastodon/features/ui/components/upload_area.tsx delete mode 100644 app/javascript/mastodon/features/ui/util/optional_motion.js delete mode 100644 app/javascript/mastodon/features/ui/util/reduced_motion.jsx diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index 6c1e0aaec1..db422f47ce 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { TransitionMotion, spring } from 'react-motion'; +import { animated, useSpring, config } from '@react-spring/web'; import { reduceMotion } from '../initial_state'; @@ -11,53 +11,49 @@ interface Props { } export const AnimatedNumber: React.FC = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); - const [direction, setDirection] = useState<1 | -1>(1); + const direction = value > previousValue ? -1 : 1; - if (previousValue !== value) { - setPreviousValue(value); - setDirection(value > previousValue ? 1 : -1); - } - - const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); - const willLeave = useCallback( - () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), - [direction], + const [styles, api] = useSpring( + () => ({ + from: { transform: `translateY(${100 * direction}%)` }, + to: { transform: 'translateY(0%)' }, + onRest() { + setPreviousValue(value); + }, + config: { ...config.gentle, duration: 200 }, + immediate: true, // This ensures that the animation is not played when the component is first rendered + }), + [value, previousValue], ); + // When the value changes, start the animation + useEffect(() => { + if (value !== previousValue) { + void api.start({ reset: true }); + } + }, [api, previousValue, value]); + if (reduceMotion) { return ; } - const styles = [ - { - key: `${value}`, - data: value, - style: { y: spring(0, { damping: 35, stiffness: 400 }) }, - }, - ]; - return ( - - {(items) => ( - - {items.map(({ key, data, style }) => ( - 0 ? 'absolute' : 'static', - transform: `translateY(${(style.y ?? 0) * 100}%)`, - }} - > - - - ))} - + + + + + {value !== previousValue && ( + + + )} - + ); }; diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx deleted file mode 100644 index 1326131009..0000000000 --- a/app/javascript/mastodon/components/poll.jsx +++ /dev/null @@ -1,248 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import escapeTextContentForBrowser from 'escape-html'; -import spring from 'react-motion/lib/spring'; - -import CheckIcon from '@/material-icons/400-24px/check.svg?react'; -import { Icon } from 'mastodon/components/icon'; -import emojify from 'mastodon/features/emoji/emoji'; -import Motion from 'mastodon/features/ui/util/optional_motion'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - closed: { - id: 'poll.closed', - defaultMessage: 'Closed', - }, - voted: { - id: 'poll.voted', - defaultMessage: 'You voted for this answer', - }, - votes: { - id: 'poll.votes', - defaultMessage: '{votes, plural, one {# vote} other {# votes}}', - }, -}); - -class Poll extends ImmutablePureComponent { - static propTypes = { - identity: identityContextPropShape, - poll: ImmutablePropTypes.record.isRequired, - status: ImmutablePropTypes.map.isRequired, - lang: PropTypes.string, - intl: PropTypes.object.isRequired, - disabled: PropTypes.bool, - refresh: PropTypes.func, - onVote: PropTypes.func, - onInteractionModal: PropTypes.func, - }; - - state = { - selected: {}, - expired: null, - }; - - static getDerivedStateFromProps (props, state) { - const { poll } = props; - const expires_at = poll.get('expires_at'); - const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now(); - return (expired === state.expired) ? null : { expired }; - } - - componentDidMount () { - this._setupTimer(); - } - - componentDidUpdate () { - this._setupTimer(); - } - - componentWillUnmount () { - clearTimeout(this._timer); - } - - _setupTimer () { - const { poll } = this.props; - clearTimeout(this._timer); - if (!this.state.expired) { - const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now(); - this._timer = setTimeout(() => { - this.setState({ expired: true }); - }, delay); - } - } - - _toggleOption = value => { - if (this.props.poll.get('multiple')) { - const tmp = { ...this.state.selected }; - if (tmp[value]) { - delete tmp[value]; - } else { - tmp[value] = true; - } - this.setState({ selected: tmp }); - } else { - const tmp = {}; - tmp[value] = true; - this.setState({ selected: tmp }); - } - }; - - handleOptionChange = ({ target: { value } }) => { - this._toggleOption(value); - }; - - handleOptionKeyPress = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - this._toggleOption(e.target.getAttribute('data-index')); - e.stopPropagation(); - e.preventDefault(); - } - }; - - handleVote = () => { - if (this.props.disabled) { - return; - } - - if (this.props.identity.signedIn) { - this.props.onVote(Object.keys(this.state.selected)); - } else { - this.props.onInteractionModal('vote', this.props.status); - } - }; - - handleRefresh = () => { - if (this.props.disabled) { - return; - } - - this.props.refresh(); - }; - - handleReveal = () => { - this.setState({ revealed: true }); - }; - - renderOption (option, optionIndex, showResults) { - const { poll, lang, disabled, intl } = this.props; - const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); - const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); - - const title = option.getIn(['translation', 'title']) || option.get('title'); - let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); - - if (!titleHtml) { - const emojiMap = emojiMap(poll); - titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); - } - - return ( -
  • - - - {showResults && ( - - {({ width }) => - - } - - )} -
  • - ); - } - - render () { - const { poll, intl } = this.props; - const { revealed, expired } = this.state; - - if (!poll) { - return null; - } - - const timeRemaining = expired ? intl.formatMessage(messages.closed) : ; - const showResults = poll.get('voted') || revealed || expired; - const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); - - let votesCount = null; - - if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { - votesCount = ; - } else { - votesCount = ; - } - - return ( -
    -
      - {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} -
    - -
    - {!showResults && } - {!showResults && <> · } - {showResults && !this.props.disabled && <> · } - {votesCount} - {poll.get('expires_at') && <> · {timeRemaining}} -
    -
    - ); - } - -} - -export default injectIntl(withIdentity(Poll)); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx new file mode 100644 index 0000000000..48f4214c26 --- /dev/null +++ b/app/javascript/mastodon/components/poll.tsx @@ -0,0 +1,352 @@ +import type { KeyboardEventHandler } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { animated, useSpring } from '@react-spring/web'; +import escapeTextContentForBrowser from 'escape-html'; +import { debounce } from 'lodash'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { fetchPoll, vote } from 'mastodon/actions/polls'; +import { Icon } from 'mastodon/components/icon'; +import emojify from 'mastodon/features/emoji/emoji'; +import { useIdentity } from 'mastodon/identity_context'; +import { reduceMotion } from 'mastodon/initial_state'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; +import type * as Model from 'mastodon/models/poll'; +import type { Status } from 'mastodon/models/status'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { RelativeTimestamp } from './relative_timestamp'; + +const messages = defineMessages({ + closed: { + id: 'poll.closed', + defaultMessage: 'Closed', + }, + voted: { + id: 'poll.voted', + defaultMessage: 'You voted for this answer', + }, + votes: { + id: 'poll.votes', + defaultMessage: '{votes, plural, one {# vote} other {# votes}}', + }, +}); + +interface PollProps { + pollId: string; + status: Status; + lang?: string; + disabled?: boolean; +} + +export const Poll: React.FC = (props) => { + const { pollId, status } = props; + + // Third party hooks + const poll = useAppSelector((state) => state.polls.get(pollId)); + const identity = useIdentity(); + const intl = useIntl(); + const dispatch = useAppDispatch(); + + // State + const [revealed, setRevealed] = useState(false); + const [selected, setSelected] = useState>({}); + + // Derived values + const expired = useMemo(() => { + if (!poll) { + return false; + } + const expiresAt = poll.get('expires_at'); + return poll.get('expired') || new Date(expiresAt).getTime() < Date.now(); + }, [poll]); + const timeRemaining = useMemo(() => { + if (!poll) { + return null; + } + if (expired) { + return intl.formatMessage(messages.closed); + } + return ; + }, [expired, intl, poll]); + const votesCount = useMemo(() => { + if (!poll) { + return null; + } + if (poll.get('voters_count')) { + return ( + + ); + } + return ( + + ); + }, [poll]); + + const disabled = + props.disabled || Object.values(selected).every((item) => !item); + + // Event handlers + const handleVote = useCallback(() => { + if (disabled) { + return; + } + + if (identity.signedIn) { + void dispatch(vote({ pollId, choices: Object.keys(selected) })); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'vote', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, [disabled, dispatch, identity, pollId, selected, status]); + + const handleReveal = useCallback(() => { + setRevealed(true); + }, []); + + const handleRefresh = useCallback(() => { + if (disabled) { + return; + } + debounce( + () => { + void dispatch(fetchPoll({ pollId })); + }, + 1000, + { leading: true }, + ); + }, [disabled, dispatch, pollId]); + + const handleOptionChange = useCallback( + (choiceIndex: number) => { + if (!poll) { + return; + } + if (poll.get('multiple')) { + setSelected((prev) => ({ + ...prev, + [choiceIndex]: !prev[choiceIndex], + })); + } else { + setSelected({ [choiceIndex]: true }); + } + }, + [poll], + ); + + if (!poll) { + return null; + } + const showResults = poll.get('voted') || revealed || expired; + + return ( +
    +
      + {poll.get('options').map((option, i) => ( + + ))} +
    + +
    + {!showResults && ( + + )} + {!showResults && ( + <> + {' '} + ·{' '} + + )} + {showResults && !disabled && ( + <> + {' '} + ·{' '} + + )} + {votesCount} + {poll.get('expires_at') && <> · {timeRemaining}} +
    +
    + ); +}; + +type PollOptionProps = Pick & { + active: boolean; + onChange: (index: number) => void; + poll: Model.Poll; + option: Model.PollOption; + index: number; + showResults?: boolean; +}; + +const PollOption: React.FC = (props) => { + const { active, lang, disabled, poll, option, index, showResults, onChange } = + props; + const voted = option.get('voted') || poll.get('own_votes')?.includes(index); + const title = + (option.getIn(['translation', 'title']) as string) || option.get('title'); + + const intl = useIntl(); + + // Derived values + const percent = useMemo(() => { + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + return pollVotesCount === 0 + ? 0 + : (option.get('votes_count') / pollVotesCount) * 100; + }, [option, poll]); + const isLeading = useMemo( + () => + poll + .get('options') + .filterNot((other) => other.get('title') === option.get('title')) + .every( + (other) => option.get('votes_count') >= other.get('votes_count'), + ), + [poll, option], + ); + const titleHtml = useMemo(() => { + let titleHtml = + (option.getIn(['translation', 'titleHtml']) as string) || + option.get('titleHtml'); + + if (!titleHtml) { + const emojiMap = makeEmojiMap(poll.get('emojis')); + titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); + } + + return titleHtml; + }, [option, poll, title]); + + // Handlers + const handleOptionChange = useCallback(() => { + onChange(index); + }, [index, onChange]); + const handleOptionKeyPress: KeyboardEventHandler = useCallback( + (event) => { + if (event.key === 'Enter' || event.key === ' ') { + onChange(index); + event.stopPropagation(); + event.preventDefault(); + } + }, + [index, onChange], + ); + + const widthSpring = useSpring({ + from: { + width: '0%', + }, + to: { + width: `${percent}%`, + }, + immediate: reduceMotion, + }); + + return ( +
  • + + + {showResults && ( + + )} +
  • + ); +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index aa8b060384..cc20888799 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import { Icon } from 'mastodon/components/icon'; -import PollContainer from 'mastodon/containers/poll_container'; +import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; @@ -245,7 +245,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index f5f38d8902..df162730cf 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -7,7 +7,7 @@ import { fromJS } from 'immutable'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import MediaGallery from 'mastodon/components/media_gallery'; import ModalRoot from 'mastodon/components/modal_root'; -import Poll from 'mastodon/components/poll'; +import { Poll } from 'mastodon/components/poll'; import Audio from 'mastodon/features/audio'; import Card from 'mastodon/features/status/components/card'; import MediaModal from 'mastodon/features/ui/components/media_modal'; diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js deleted file mode 100644 index 7ca840138d..0000000000 --- a/app/javascript/mastodon/containers/poll_container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import { openModal } from 'mastodon/actions/modal'; -import { fetchPoll, vote } from 'mastodon/actions/polls'; -import Poll from 'mastodon/components/poll'; - -const mapDispatchToProps = (dispatch, { pollId }) => ({ - refresh: debounce( - () => { - dispatch(fetchPoll({ pollId })); - }, - 1000, - { leading: true }, - ), - - onVote (choices) { - dispatch(vote({ pollId, choices })); - }, - - onInteractionModal (type, status) { - dispatch(openModal({ - modalType: 'INTERACTION', - modalProps: { - type, - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - })); - } -}); - -const mapStateToProps = (state, { pollId }) => ({ - poll: state.polls.get(pollId), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 75f4720fb3..3611a74b4f 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -20,7 +20,6 @@ import PollButtonContainer from '../containers/poll_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; -import WarningContainer from '../containers/warning_container'; import { countableText } from '../util/counter'; import { CharacterCounter } from './character_counter'; @@ -30,6 +29,7 @@ import { NavigationBar } from './navigation_bar'; import { PollForm } from "./poll_form"; import { ReplyIndicator } from './reply_indicator'; import { UploadForm } from './upload_form'; +import { Warning } from './warning'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -233,7 +233,7 @@ class ComposeForm extends ImmutablePureComponent {
    {!withoutNavigation && } - +
    diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.jsx b/app/javascript/mastodon/features/compose/components/upload_progress.jsx deleted file mode 100644 index fd0c8f4530..0000000000 --- a/app/javascript/mastodon/features/compose/components/upload_progress.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import spring from 'react-motion/lib/spring'; - -import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -import Motion from '../../ui/util/optional_motion'; - -export const UploadProgress = ({ active, progress, isProcessing }) => { - if (!active) { - return null; - } - - let message; - - if (isProcessing) { - message = ; - } else { - message = ; - } - - return ( -
    - - -
    - {message} - -
    - - {({ width }) => -
    - } - -
    -
    -
    - ); -}; - -UploadProgress.propTypes = { - active: PropTypes.bool, - progress: PropTypes.number, - isProcessing: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.tsx b/app/javascript/mastodon/features/compose/components/upload_progress.tsx new file mode 100644 index 0000000000..35af03306f --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_progress.tsx @@ -0,0 +1,61 @@ +import { FormattedMessage } from 'react-intl'; + +import { animated, useSpring } from '@react-spring/web'; + +import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; +import { Icon } from 'mastodon/components/icon'; +import { reduceMotion } from 'mastodon/initial_state'; + +interface UploadProgressProps { + active: boolean; + progress: number; + isProcessing: boolean; +} + +export const UploadProgress: React.FC = ({ + active, + progress, + isProcessing, +}) => { + const styles = useSpring({ + from: { width: '0%' }, + to: { width: `${progress}%` }, + reset: true, + immediate: reduceMotion, + }); + if (!active) { + return null; + } + + let message; + + if (isProcessing) { + message = ( + + ); + } else { + message = ( + + ); + } + + return ( +
    + + +
    + {message} + +
    + +
    +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/compose/components/warning.jsx b/app/javascript/mastodon/features/compose/components/warning.jsx deleted file mode 100644 index c5babc30a5..0000000000 --- a/app/javascript/mastodon/features/compose/components/warning.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import spring from 'react-motion/lib/spring'; - -import Motion from '../../ui/util/optional_motion'; - -export default class Warning extends PureComponent { - - static propTypes = { - message: PropTypes.node.isRequired, - }; - - render () { - const { message } = this.props; - - return ( - - {({ opacity, scaleX, scaleY }) => ( -
    - {message} -
    - )} -
    - ); - } - -} diff --git a/app/javascript/mastodon/features/compose/components/warning.tsx b/app/javascript/mastodon/features/compose/components/warning.tsx new file mode 100644 index 0000000000..5c5482fb9d --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/warning.tsx @@ -0,0 +1,96 @@ +import { FormattedMessage } from 'react-intl'; + +import { createSelector } from '@reduxjs/toolkit'; + +import { animated, useSpring } from '@react-spring/web'; + +import { me } from 'mastodon/initial_state'; +import { useAppSelector } from 'mastodon/store'; +import type { RootState } from 'mastodon/store'; +import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; + +const selector = createSelector( + (state: RootState) => state.compose.get('privacy') as string, + (state: RootState) => !!state.compose.getIn(['accounts', me, 'locked']), + (state: RootState) => state.compose.get('text') as string, + (privacy, locked, text) => ({ + needsLockWarning: privacy === 'private' && !locked, + hashtagWarning: privacy !== 'public' && HASHTAG_PATTERN_REGEX.test(text), + directMessageWarning: privacy === 'direct', + }), +); + +export const Warning = () => { + const { needsLockWarning, hashtagWarning, directMessageWarning } = + useAppSelector(selector); + if (needsLockWarning) { + return ( + + + + + ), + }} + /> + + ); + } + + if (hashtagWarning) { + return ( + + + + ); + } + + if (directMessageWarning) { + return ( + + {' '} + + + + + ); + } + + return null; +}; + +export const WarningMessage: React.FC = ({ + children, +}) => { + const styles = useSpring({ + from: { + opacity: 0, + transform: 'scale(0.85, 0.75)', + }, + to: { + opacity: 1, + transform: 'scale(1, 1)', + }, + }); + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx deleted file mode 100644 index 7a212342c7..0000000000 --- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { me } from 'mastodon/initial_state'; -import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; - -import Warning from '../components/warning'; - -const mapStateToProps = state => ({ - needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), - hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), - directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', -}); - -const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { - if (needsLockWarning) { - return }} />} />; - } - - if (hashtagWarning) { - return } />; - } - - if (directMessageWarning) { - const message = ( - - - - ); - - return ; - } - - return null; -}; - -WarningWrapper.propTypes = { - needsLockWarning: PropTypes.bool, - hashtagWarning: PropTypes.bool, - directMessageWarning: PropTypes.bool, -}; - -export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx index ad66d2e5fa..f5f593860f 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.jsx +++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { PureComponent, useCallback, useMemo } from 'react'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; @@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; +import { animated, useTransition } from '@react-spring/web'; import ReactSwipeableViews from 'react-swipeable-views'; import elephantUIPlane from '@/images/elephant_ui_plane.svg'; @@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent { } return ( - + ); } } -class ReactionsBar extends ImmutablePureComponent { +const ReactionsBar = ({ + announcementId, + reactions, + emojiMap, + addReaction, + removeReaction, +}) => { + const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]); - static propTypes = { - announcementId: PropTypes.string.isRequired, - reactions: ImmutablePropTypes.list.isRequired, - addReaction: PropTypes.func.isRequired, - removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, - }; + const handleEmojiPick = useCallback((emoji) => { + addReaction(announcementId, emoji.native.replaceAll(/:/g, '')); + }, [addReaction, announcementId]); - handleEmojiPick = data => { - const { addReaction, announcementId } = this.props; - addReaction(announcementId, data.native.replace(/:/g, '')); - }; + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + immediate: reduceMotion, + keys: visibleReactions.map(x => x.get('name')), + }); - willEnter () { - return { scale: reduceMotion ? 1 : 0 }; - } + return ( +
    + {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + addReaction={addReaction} + removeReaction={removeReaction} + announcementId={announcementId} + emojiMap={emojiMap} + /> + ))} - willLeave () { - return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; - } - - render () { - const { reactions } = this.props; - const visibleReactions = reactions.filter(x => x.get('count') > 0); - - const styles = visibleReactions.map(reaction => ({ - key: reaction.get('name'), - data: reaction, - style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, - })).toArray(); - - return ( - - {items => ( -
    - {items.map(({ key, data, style }) => ( - - ))} - - {visibleReactions.size < 8 && } />} -
    - )} -
    - ); - } - -} + {visibleReactions.length < 8 && ( + } + /> + )} +
    + ); +}; +ReactionsBar.propTypes = { + announcementId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, +}; class Announcement extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/ui/components/upload_area.jsx b/app/javascript/mastodon/features/ui/components/upload_area.jsx deleted file mode 100644 index b2702d35ef..0000000000 --- a/app/javascript/mastodon/features/ui/components/upload_area.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import spring from 'react-motion/lib/spring'; - -import Motion from '../util/optional_motion'; - -export default class UploadArea extends PureComponent { - - static propTypes = { - active: PropTypes.bool, - onClose: PropTypes.func, - }; - - handleKeyUp = (e) => { - const keyCode = e.keyCode; - if (this.props.active) { - switch(keyCode) { - case 27: - e.preventDefault(); - e.stopPropagation(); - this.props.onClose(); - break; - } - } - }; - - componentDidMount () { - window.addEventListener('keyup', this.handleKeyUp, false); - } - - componentWillUnmount () { - window.removeEventListener('keyup', this.handleKeyUp); - } - - render () { - const { active } = this.props; - - return ( - - {({ backgroundOpacity, backgroundScale }) => ( -
    -
    -
    -
    -
    -
    - )} - - ); - } - -} diff --git a/app/javascript/mastodon/features/ui/components/upload_area.tsx b/app/javascript/mastodon/features/ui/components/upload_area.tsx new file mode 100644 index 0000000000..87ac090e7e --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/upload_area.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { animated, config, useSpring } from '@react-spring/web'; + +import { reduceMotion } from 'mastodon/initial_state'; + +interface UploadAreaProps { + active?: boolean; + onClose: () => void; +} + +export const UploadArea: React.FC = ({ active, onClose }) => { + const handleKeyUp = useCallback( + (e: KeyboardEvent) => { + if (active && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }, + [active, onClose], + ); + + useEffect(() => { + window.addEventListener('keyup', handleKeyUp, false); + + return () => { + window.removeEventListener('keyup', handleKeyUp); + }; + }, [handleKeyUp]); + + const wrapperAnimStyles = useSpring({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + reverse: !active, + immediate: reduceMotion, + }); + const backgroundAnimStyles = useSpring({ + from: { + transform: 'scale(0.95)', + }, + to: { + transform: 'scale(1)', + }, + reverse: !active, + config: config.wobbly, + immediate: reduceMotion, + }); + + return ( + +
    + +
    + +
    +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index d5ff6d148c..a1cb8212d2 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -30,7 +30,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding import BundleColumnError from './components/bundle_column_error'; import Header from './components/header'; -import UploadArea from './components/upload_area'; +import { UploadArea } from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js deleted file mode 100644 index 0b6d4d97f7..0000000000 --- a/app/javascript/mastodon/features/ui/util/optional_motion.js +++ /dev/null @@ -1,7 +0,0 @@ -import Motion from 'react-motion/lib/Motion'; - -import { reduceMotion } from '../../../initial_state'; - -import ReducedMotion from './reduced_motion'; - -export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx deleted file mode 100644 index fd044497f8..0000000000 --- a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx +++ /dev/null @@ -1,45 +0,0 @@ -// Like react-motion's Motion, but reduces all animations to cross-fades -// for the benefit of users with motion sickness. -import PropTypes from 'prop-types'; -import { Component } from 'react'; - -import Motion from 'react-motion/lib/Motion'; - -const stylesToKeep = ['opacity', 'backgroundOpacity']; - -const extractValue = (value) => { - // This is either an object with a "val" property or it's a number - return (typeof value === 'object' && value && 'val' in value) ? value.val : value; -}; - -class ReducedMotion extends Component { - - static propTypes = { - defaultStyle: PropTypes.object, - style: PropTypes.object, - children: PropTypes.func, - }; - - render() { - - const { style, defaultStyle, children } = this.props; - - Object.keys(style).forEach(key => { - if (stylesToKeep.includes(key)) { - return; - } - // If it's setting an x or height or scale or some other value, we need - // to preserve the end-state value without actually animating it - style[key] = defaultStyle[key] = extractValue(style[key]); - }); - - return ( - - {children} - - ); - } - -} - -export default ReducedMotion; diff --git a/package.json b/package.json index 38c8a3abb1..9053a45fd1 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.0.0", - "react-motion": "^0.5.2", "react-overlays": "^5.2.1", "react-redux": "^9.0.4", "react-redux-loading-bar": "^5.0.8", @@ -164,7 +163,6 @@ "@types/react-dom": "^18.2.4", "@types/react-helmet": "^6.1.6", "@types/react-immutable-proptypes": "^2.1.0", - "@types/react-motion": "^0.0.40", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-sparklines": "^1.7.2", diff --git a/yarn.lock b/yarn.lock index 66e69f563b..cec0741a60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2771,7 +2771,6 @@ __metadata: "@types/react-dom": "npm:^18.2.4" "@types/react-helmet": "npm:^6.1.6" "@types/react-immutable-proptypes": "npm:^2.1.0" - "@types/react-motion": "npm:^0.0.40" "@types/react-router": "npm:^5.1.20" "@types/react-router-dom": "npm:^5.3.3" "@types/react-sparklines": "npm:^1.7.2" @@ -2850,7 +2849,6 @@ __metadata: react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.0.0" - react-motion: "npm:^0.5.2" react-overlays: "npm:^5.2.1" react-redux: "npm:^9.0.4" react-redux-loading-bar: "npm:^5.0.8" @@ -4050,15 +4048,6 @@ __metadata: languageName: node linkType: hard -"@types/react-motion@npm:^0.0.40": - version: 0.0.40 - resolution: "@types/react-motion@npm:0.0.40" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/8a560051be917833fdbe051185b53aeafbe8657968ac8e073ac874b9a55c6f16e3793748b13cfb9bd6d9a3d27aba116d6f8f296ec1950f4175dc94d17c5e8470 - languageName: node - linkType: hard - "@types/react-router-dom@npm:^5.3.3": version: 5.3.3 resolution: "@types/react-router-dom@npm:5.3.3" @@ -13189,20 +13178,6 @@ __metadata: languageName: node linkType: hard -"performance-now@npm:^0.2.0": - version: 0.2.0 - resolution: "performance-now@npm:0.2.0" - checksum: 10c0/d7f3824e443491208f7124b45d3280dbff889f8f048c3aee507109c24644d51a226eb07fd7ac51dd0eef144639590c57410c2d167bd4fdf0c5caa0101a449c3d - languageName: node - linkType: hard - -"performance-now@npm:^2.1.0": - version: 2.1.0 - resolution: "performance-now@npm:2.1.0" - checksum: 10c0/22c54de06f269e29f640e0e075207af57de5052a3d15e360c09b9a8663f393f6f45902006c1e71aa8a5a1cdfb1a47fe268826f8496d6425c362f00f5bc3e85d9 - languageName: node - linkType: hard - "pg-cloudflare@npm:^1.1.1": version: 1.1.1 resolution: "pg-cloudflare@npm:1.1.1" @@ -14465,7 +14440,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -14596,15 +14571,6 @@ __metadata: languageName: node linkType: hard -"raf@npm:^3.1.0": - version: 3.4.1 - resolution: "raf@npm:3.4.1" - dependencies: - performance-now: "npm:^2.1.0" - checksum: 10c0/337f0853c9e6a77647b0f499beedafea5d6facfb9f2d488a624f88b03df2be72b8a0e7f9118a3ff811377d534912039a3311815700d2b6d2313f82f736f9eb6e - languageName: node - linkType: hard - "randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -14777,19 +14743,6 @@ __metadata: languageName: node linkType: hard -"react-motion@npm:^0.5.2": - version: 0.5.2 - resolution: "react-motion@npm:0.5.2" - dependencies: - performance-now: "npm:^0.2.0" - prop-types: "npm:^15.5.8" - raf: "npm:^3.1.0" - peerDependencies: - react: ^0.14.9 || ^15.3.0 || ^16.0.0 - checksum: 10c0/4ea6f1cc7079f0161fd786cc755133a822d87d9c0510369b8fb348d9ad602111efa2e3496dbcc390c967229e39e3eb5f6dd5dd6d3d124289443de31d6035a6c8 - languageName: node - linkType: hard - "react-overlays@npm:^5.2.1": version: 5.2.1 resolution: "react-overlays@npm:5.2.1"