diff --git a/Gemfile b/Gemfile index 6abb075c1c..fc6ec889a0 100644 --- a/Gemfile +++ b/Gemfile @@ -61,6 +61,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 87d34d639a..1164e5a98a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -203,6 +203,7 @@ GEM railties (>= 5) dotenv (3.1.7) drb (2.2.1) + ed25519 (1.3.0) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -381,6 +382,12 @@ GEM railties (>= 6.1) rexml link_header (0.0.8) + linzer (0.6.1) + ed25519 (~> 1.3, >= 1.3.0) + openssl (~> 3.0, >= 3.0.0) + rack (>= 2.2, < 4.0) + starry (~> 0.2) + uri (~> 1.0, >= 1.0.2) llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) @@ -793,6 +800,8 @@ GEM simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.26) + starry (0.2.0) + base64 stoplight (4.1.0) redlock (~> 1.0) stringio (3.1.2) @@ -941,6 +950,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..ac5226d438 --- /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.require(:provider).permit(enabled_capabilities: {}) + 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..a189a5e226 --- /dev/null +++ b/app/controllers/api/fasp/base_controller.rb @@ -0,0 +1,76 @@ +# 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 :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_raw, 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.raw_private_key) + signature = Linzer.sign(key, message, %w(@status content-digest)) + + response.headers.merge!(signature.to_h) + 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..40e711bd0c --- /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: admin_fasp_provider_url(current_provider), + } + end +end diff --git a/app/lib/fasp/request.rb b/app/lib/fasp/request.rb new file mode 100644 index 0000000000..acb1d2ff25 --- /dev/null +++ b/app/lib/fasp/request.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Fasp::Request + def initialize(provider) + @provider = provider + end + + def get(path) + url = @provider.url(path) + response = HTTP.headers(headers('GET', url)).get(url) + validate!(response) + + response.parse + end + + def post(path, body: nil) + url = @provider.url(path) + body = body.to_json + response = HTTP.headers(headers('POST', url, body)).post(url, body:) + + response.parse if response.body.present? + end + + private + + def 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.raw_private_key, @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 'content-digest missing' if content_digest_header.blank? + raise 'content-digest does not match' if content_digest_header != content_digest(response.body) + + signature_input = response.headers['signature-input'].encode('UTF-8') + raise '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_raw) + 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/debug_callback.rb b/app/models/fasp/debug_callback.rb new file mode 100644 index 0000000000..0a9897ffae --- /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 +# request_body :text +# 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..a3dc58de49 --- /dev/null +++ b/app/models/fasp/provider.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: fasp_providers +# +# id :bigint(8) not null, primary key +# base_url :string not null +# capabilities :jsonb +# 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 + + before_create :create_keypair + + def enabled_capabilities=(hash) + capabilities.each do |capability| + capability['enabled'] = hash[capability['id']] == '1' + end + save! + end + + def capability?(capability_name, only_enabled: true) + return false unless confirmed? + + capabilities.present? && capabilities.any? do |capability| + capability['id'] == capability_name && + (only_enabled ? capability['enabled'] : true) + 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) + 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 +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..7f8164b33c --- /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.providers.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..1591fc4e8c --- /dev/null +++ b/app/views/admin/fasp/providers/_provider.html.haml @@ -0,0 +1,17 @@ +%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 + - unless provider.confirmed? + = table_link_to 'link', t('admin.fasp.providers.finish_registration'), new_admin_fasp_provider_registration_path(provider) + - if provider.sign_in_url.present? + = table_link_to 'link', t('admin.fasp.providers.sign_in'), provider.sign_in_url, target: '_blank' + - if provider.capability?('callback') + = table_link_to 'link', t('admin.fasp.providers.callback'), admin_fasp_provider_debug_calls_path(provider), data: { method: :post } + + = table_link_to 'close', t('admin.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..ed9496b11c --- /dev/null +++ b/app/views/admin/fasp/providers/edit.html.haml @@ -0,0 +1,21 @@ +- 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') + + - f.object.capabilities.each do |capability| + .fields-group + .input.with_label.boolean.optional.field_with_hint + .label_input + %label.boolean.optional{ for: "provider_enabled_capabilities_#{capability['id']}" } + = capability['id'] + .label_input__wrapper + = hidden_field_tag "provider[enabled_capabilities][#{capability['id']}]", '0', id: false + %label.checkbox + = check_box_tag "provider[enabled_capabilities][#{capability['id']}]", class: 'optional boolean' + + .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..8d11dd633c --- /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 + %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..d0dfc61b53 --- /dev/null +++ b/app/views/admin/fasp/registrations/new.html.haml @@ -0,0 +1,20 @@ +- 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 |_form| + .fields-group + .actions + = link_to t('admin.fasp.providers.registrations.reject'), admin_fasp_provider_path(@provider), data: { method: :delete }, class: 'button negative' + %button.button= t('admin.fasp.providers.registrations.confirm') 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..d0c29bde6a --- /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('description'), t('admin.fasp.providers.providers')]), admin_fasp_providers_path + primary.item :debug_callbacks, safe_join([material_symbol('group'), t('admin.fasp.debug.callbacks.title')]), admin_fasp_debug_callbacks_path diff --git a/config/locales/en.yml b/config/locales/en.yml index ab76b462ee..8c5b03c68a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -555,6 +555,36 @@ en: total_storage: Media attachments totals_time_period_hint_html: The totals displayed below include data for all time. unknown_instance: There is currently no record of this domain on this server. + 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 + debug: Debug call + delete: Delete + edit: Edit Provider + finish_registration: Finish registration + name: Name + public_key_fingerprint: Public key fingerprint + providers: Providers + 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 invites: deactivate_all: Deactivate all filter: diff --git a/config/navigation.rb b/config/navigation.rb index de9e530f58..c3ce45f4ff 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('captive_portal'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) } 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 3909dd1b77..d9778438bc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -195,6 +195,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..69e211f275 --- /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 + 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..0542b2d353 --- /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 + t.text :request_body + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 87d42a6051..e5e9d334c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -444,6 +444,32 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_16_224825) 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" + t.text "request_body" + 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" + 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 @@ -1283,6 +1309,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_16_224825) 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..a474c78cb8 --- /dev/null +++ b/spec/fabricators/fasp/debug_callback_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator('Fasp::DebugCallback') do + fasp_provider nil + ip 'MyString' + 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..7ee508619a --- /dev/null +++ b/spec/fabricators/fasp/provider_fabricator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Fabricator('Fasp::Provider') do + name 'MyString' + base_url 'MyString' + sign_in_url 'MyString' + remote_identifier 'MyString' + provider_public_key_pem 'MyString' + server_private_key_pem 'MyString' + capabilities '' + privacy_policy '' + contact_email 'MyString' + fediverse_account 'MyString' +end diff --git a/spec/models/fasp/debug_callback_spec.rb b/spec/models/fasp/debug_callback_spec.rb new file mode 100644 index 0000000000..f3cca6fade --- /dev/null +++ b/spec/models/fasp/debug_callback_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fasp::DebugCallback do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/fasp/provider_spec.rb b/spec/models/fasp/provider_spec.rb new file mode 100644 index 0000000000..d3d528efb4 --- /dev/null +++ b/spec/models/fasp/provider_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fasp::Provider do + pending "add some examples to (or delete) #{__FILE__}" +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..d6cec950ea --- /dev/null +++ b/spec/policies/admin/fasp/provider_policy_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::Fasp::ProviderPolicy, type: :policy do + subject { described_class } + + let(:user) { User.new } + + permissions '.scope' do + pending "add some examples to (or delete) #{__FILE__}" + end + + permissions :show? do + pending "add some examples to (or delete) #{__FILE__}" + end + + permissions :create? do + pending "add some examples to (or delete) #{__FILE__}" + end + + permissions :update? do + pending "add some examples to (or delete) #{__FILE__}" + end + + permissions :destroy? do + pending "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/admin/fasp/debug/callbacks_spec.rb b/spec/requests/admin/fasp/debug/callbacks_spec.rb new file mode 100644 index 0000000000..8e8eee3e6e --- /dev/null +++ b/spec/requests/admin/fasp/debug/callbacks_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Fasp::Debug::Callbacks' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/admin/fasp/debug_calls_spec.rb b/spec/requests/admin/fasp/debug_calls_spec.rb new file mode 100644 index 0000000000..a21053799b --- /dev/null +++ b/spec/requests/admin/fasp/debug_calls_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Fasp::DebugCalls' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/admin/fasp/providers_spec.rb b/spec/requests/admin/fasp/providers_spec.rb new file mode 100644 index 0000000000..1c0d49f1be --- /dev/null +++ b/spec/requests/admin/fasp/providers_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Fasp::Providers' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/admin/fasp/registrations_spec.rb b/spec/requests/admin/fasp/registrations_spec.rb new file mode 100644 index 0000000000..b465d50a55 --- /dev/null +++ b/spec/requests/admin/fasp/registrations_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Fasp::Registrations' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + 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..a94e4a1b91 --- /dev/null +++ b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + 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..2504e70489 --- /dev/null +++ b/spec/requests/api/fasp/registrations_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::Fasp::Registrations' do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + end +end