Add ActivityPub actor representing the entire server (#11321)
* Add support for an instance actor * Skip username validation for local Application accounts * Add migration script to create instance actor * Make Codeclimate happy * Switch to id -99 for instance actor * Remove unused `icon` and `image` attributes from instance actor * Use if/elsif/else instead of return + ternary operator * Add instance actor to fresh installs * Use instance actor as instance representative Use instance actor for forwarding reports, relay operations, and spam auto-reporting. * Seed database in test environment * Fix single-user mode * Fix tests * Fix specs to accomodate for an extra `Account` * Auto-reject follows on instance actor Following an instance actor might make sense, but we are not handling that right now, so auto-reject. * Fix webfinger lookup and serialization for instance actor * Rename instance actor * Make it clear in the HTML view that the instance actor should not be blocked * Raise cache time for instance actor as there's no dynamic content * Re-use /about/more with a flash message for instance actor profile
This commit is contained in:
		| @@ -11,7 +11,9 @@ class AboutController < ApplicationController | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def more; end | ||||
|   def more | ||||
|     flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] | ||||
|   end | ||||
|  | ||||
|   def terms; end | ||||
|  | ||||
|   | ||||
| @@ -91,7 +91,7 @@ class ApplicationController < ActionController::Base | ||||
|   end | ||||
|  | ||||
|   def single_user_mode? | ||||
|     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? | ||||
|     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? | ||||
|   end | ||||
|  | ||||
|   def use_seamless_external_login? | ||||
|   | ||||
| @@ -58,7 +58,7 @@ class HomeController < ApplicationController | ||||
|     if request.path.start_with?('/web') | ||||
|       new_user_session_path | ||||
|     elsif single_user_mode? | ||||
|       short_account_path(Account.local.without_suspended.first) | ||||
|       short_account_path(Account.local.without_suspended.where('id > 0').first) | ||||
|     else | ||||
|       about_path | ||||
|     end | ||||
|   | ||||
							
								
								
									
										20
									
								
								app/controllers/instance_actors_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/controllers/instance_actors_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class InstanceActorsController < ApplicationController | ||||
|   include AccountControllerConcern | ||||
|  | ||||
|   def show | ||||
|     expires_in 10.minutes, public: true | ||||
|     render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_account | ||||
|     @account = Account.find(-99) | ||||
|   end | ||||
|  | ||||
|   def restrict_fields_to | ||||
|     %i(id type preferred_username inbox public_key endpoints url manually_approves_followers) | ||||
|   end | ||||
| end | ||||
| @@ -145,6 +145,10 @@ | ||||
|     min-height: 100%; | ||||
|   } | ||||
|  | ||||
|   .flash-message { | ||||
|     margin-bottom: 10px; | ||||
|   } | ||||
|  | ||||
|   @media screen and (max-width: 738px) { | ||||
|     grid-template-columns: minmax(0, 50%) minmax(0, 50%); | ||||
|  | ||||
|   | ||||
| @@ -8,7 +8,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity | ||||
|  | ||||
|     return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account) | ||||
|  | ||||
|     if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? | ||||
|     if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? || target_account.instance_actor? | ||||
|       reject_follow_request!(target_account) | ||||
|       return | ||||
|     end | ||||
|   | ||||
| @@ -17,7 +17,7 @@ class ActivityPub::TagManager | ||||
|  | ||||
|     case target.object_type | ||||
|     when :person | ||||
|       short_account_url(target) | ||||
|       target.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(target) | ||||
|     when :note, :comment, :activity | ||||
|       return activity_account_status_url(target.account, target) if target.reblog? | ||||
|       short_account_status_url(target.account, target) | ||||
| @@ -29,7 +29,7 @@ class ActivityPub::TagManager | ||||
|  | ||||
|     case target.object_type | ||||
|     when :person | ||||
|       account_url(target) | ||||
|       target.instance_actor? ? instance_actor_url : account_url(target) | ||||
|     when :note, :comment, :activity | ||||
|       return activity_account_status_url(target.account, target) if target.reblog? | ||||
|       account_status_url(target.account, target) | ||||
| @@ -119,6 +119,7 @@ class ActivityPub::TagManager | ||||
|  | ||||
|   def uri_to_local_id(uri, param = :id) | ||||
|     path_params = Rails.application.routes.recognize_path(uri) | ||||
|     path_params[:username] = Rails.configuration.x.local_domain if path_params[:controller] == 'instance_actors' | ||||
|     path_params[param] | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -23,11 +23,17 @@ class WebfingerResource | ||||
|   def username_from_url | ||||
|     if account_show_page? | ||||
|       path_params[:username] | ||||
|     elsif instance_actor_page? | ||||
|       Rails.configuration.x.local_domain | ||||
|     else | ||||
|       raise ActiveRecord::RecordNotFound | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def instance_actor_page? | ||||
|     path_params[:controller] == 'instance_actors' | ||||
|   end | ||||
|  | ||||
|   def account_show_page? | ||||
|     path_params[:controller] == 'accounts' && path_params[:action] == 'show' | ||||
|   end | ||||
|   | ||||
| @@ -77,7 +77,7 @@ class Account < ApplicationRecord | ||||
|   validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? } | ||||
|  | ||||
|   # Local user validations | ||||
|   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } | ||||
|   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' } | ||||
|   validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? } | ||||
|   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } | ||||
|   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? } | ||||
| @@ -139,6 +139,10 @@ class Account < ApplicationRecord | ||||
|     %w(Application Service).include? actor_type | ||||
|   end | ||||
|  | ||||
|   def instance_actor? | ||||
|     id == -99 | ||||
|   end | ||||
|  | ||||
|   alias bot bot? | ||||
|  | ||||
|   def bot=(val) | ||||
| @@ -498,7 +502,7 @@ class Account < ApplicationRecord | ||||
|   end | ||||
|  | ||||
|   def generate_keys | ||||
|     return unless local? && !Rails.env.test? | ||||
|     return unless local? && private_key.blank? && public_key.blank? | ||||
|  | ||||
|     keypair = OpenSSL::PKey::RSA.new(2048) | ||||
|     self.private_key = keypair.to_pem | ||||
|   | ||||
| @@ -13,7 +13,7 @@ module AccountFinderConcern | ||||
|     end | ||||
|  | ||||
|     def representative | ||||
|       find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) || Account.local.without_suspended.first | ||||
|       Account.find(-99) | ||||
|     end | ||||
|  | ||||
|     def find_local(username) | ||||
|   | ||||
| @@ -39,11 +39,17 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer | ||||
|   delegate :moved?, to: :object | ||||
|  | ||||
|   def id | ||||
|     account_url(object) | ||||
|     object.instance_actor? ? instance_actor_url : account_url(object) | ||||
|   end | ||||
|  | ||||
|   def type | ||||
|     object.bot? ? 'Service' : 'Person' | ||||
|     if object.instance_actor? | ||||
|       'Application' | ||||
|     elsif object.bot? | ||||
|       'Service' | ||||
|     else | ||||
|       'Person' | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def following | ||||
| @@ -55,7 +61,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer | ||||
|   end | ||||
|  | ||||
|   def inbox | ||||
|     account_inbox_url(object) | ||||
|     object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object) | ||||
|   end | ||||
|  | ||||
|   def outbox | ||||
| @@ -95,7 +101,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer | ||||
|   end | ||||
|  | ||||
|   def url | ||||
|     short_account_url(object) | ||||
|     object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object) | ||||
|   end | ||||
|  | ||||
|   def avatar_exists? | ||||
|   | ||||
| @@ -10,15 +10,26 @@ class WebfingerSerializer < ActiveModel::Serializer | ||||
|   end | ||||
|  | ||||
|   def aliases | ||||
|     [short_account_url(object), account_url(object)] | ||||
|     if object.instance_actor? | ||||
|       [instance_actor_url] | ||||
|     else | ||||
|       [short_account_url(object), account_url(object)] | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def links | ||||
|     [ | ||||
|       { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, | ||||
|       { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, | ||||
|       { rel: 'self', type: 'application/activity+json', href: account_url(object) }, | ||||
|       { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, | ||||
|     ] | ||||
|     if object.instance_actor? | ||||
|       [ | ||||
|         { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) }, | ||||
|         { rel: 'self', type: 'application/activity+json', href: instance_actor_url }, | ||||
|       ] | ||||
|     else | ||||
|       [ | ||||
|         { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, | ||||
|         { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, | ||||
|         { rel: 'self', type: 'application/activity+json', href: account_url(object) }, | ||||
|         { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, | ||||
|       ] | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -43,5 +43,7 @@ | ||||
|           = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email | ||||
|  | ||||
|   .column-3 | ||||
|     = render 'application/flashes' | ||||
|  | ||||
|     .box-widget | ||||
|       .rich-formatting= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html') | ||||
|   | ||||
| @@ -4,30 +4,47 @@ doc << Ox::Element.new('XRD').tap do |xrd| | ||||
|   xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0' | ||||
|  | ||||
|   xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s) | ||||
|   xrd << (Ox::Element.new('Alias') << short_account_url(@account)) | ||||
|   xrd << (Ox::Element.new('Alias') << account_url(@account)) | ||||
|  | ||||
|   xrd << Ox::Element.new('Link').tap do |link| | ||||
|     link['rel']      = 'http://webfinger.net/rel/profile-page' | ||||
|     link['type']     = 'text/html' | ||||
|     link['href']     = short_account_url(@account) | ||||
|   end | ||||
|   if @account.instance_actor? | ||||
|     xrd << (Ox::Element.new('Alias') << instance_actor_url) | ||||
|  | ||||
|   xrd << Ox::Element.new('Link').tap do |link| | ||||
|     link['rel']      = 'http://schemas.google.com/g/2010#updates-from' | ||||
|     link['type']     = 'application/atom+xml' | ||||
|     link['href']     = account_url(@account, format: 'atom') | ||||
|   end | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'http://webfinger.net/rel/profile-page' | ||||
|       link['type']     = 'text/html' | ||||
|       link['href']     = about_more_url(instance_actor: true) | ||||
|     end | ||||
|  | ||||
|   xrd << Ox::Element.new('Link').tap do |link| | ||||
|     link['rel']      = 'self' | ||||
|     link['type']     = 'application/activity+json' | ||||
|     link['href']     = account_url(@account) | ||||
|   end | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'self' | ||||
|       link['type']     = 'application/activity+json' | ||||
|       link['href']     = instance_actor_url | ||||
|     end | ||||
|   else | ||||
|     xrd << (Ox::Element.new('Alias') << short_account_url(@account)) | ||||
|     xrd << (Ox::Element.new('Alias') << account_url(@account)) | ||||
|  | ||||
|   xrd << Ox::Element.new('Link').tap do |link| | ||||
|     link['rel']      = 'http://ostatus.org/schema/1.0/subscribe' | ||||
|     link['template'] = "#{authorize_interaction_url}?acct={uri}" | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'http://webfinger.net/rel/profile-page' | ||||
|       link['type']     = 'text/html' | ||||
|       link['href']     = short_account_url(@account) | ||||
|     end | ||||
|  | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'http://schemas.google.com/g/2010#updates-from' | ||||
|       link['type']     = 'application/atom+xml' | ||||
|       link['href']     = account_url(@account, format: 'atom') | ||||
|     end | ||||
|  | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'self' | ||||
|       link['type']     = 'application/activity+json' | ||||
|       link['href']     = account_url(@account) | ||||
|     end | ||||
|  | ||||
|     xrd << Ox::Element.new('Link').tap do |link| | ||||
|       link['rel']      = 'http://ostatus.org/schema/1.0/subscribe' | ||||
|       link['template'] = "#{authorize_interaction_url}?acct={uri}" | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | ||||
|   | ||||
| @@ -24,6 +24,9 @@ en: | ||||
|     generic_description: "%{domain} is one server in the network" | ||||
|     get_apps: Try a mobile app | ||||
|     hosted_on: Mastodon hosted on %{domain} | ||||
|     instance_actor_flash: | | ||||
|       This account is a virtual actor used to represent the server itself and not any individual user. | ||||
|       It is used for federation purposes and should not be blocked unless you want to block the whole instance, in which case you should use a domain block. | ||||
|     learn_more: Learn more | ||||
|     privacy_policy: Privacy policy | ||||
|     see_whats_happening: See what's happening | ||||
|   | ||||
| @@ -28,6 +28,10 @@ Rails.application.routes.draw do | ||||
|   get 'intent', to: 'intents#show' | ||||
|   get 'custom.css', to: 'custom_css#show', as: :custom_css | ||||
|  | ||||
|   resource :instance_actor, path: 'actor', only: [:show] do | ||||
|     resource :inbox, only: [:create], module: :activitypub | ||||
|   end | ||||
|  | ||||
|   devise_scope :user do | ||||
|     get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite | ||||
|     match '/auth/finish_signup' => 'auth/confirmations#finish_signup', via: [:get, :patch], as: :finish_signup | ||||
|   | ||||
							
								
								
									
										9
									
								
								db/migrate/20190715164535_add_instance_actor.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								db/migrate/20190715164535_add_instance_actor.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| class AddInstanceActor < ActiveRecord::Migration[5.2] | ||||
|   def up | ||||
|     Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain) | ||||
|   end | ||||
|  | ||||
|   def down | ||||
|     Account.find_by(id: -99, actor_type: 'Application').destroy! | ||||
|   end | ||||
| end | ||||
| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 2019_07_06_233204) do | ||||
| ActiveRecord::Schema.define(version: 2019_07_15_164535) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| Doorkeeper::Application.create!(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow') | ||||
|  | ||||
| domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain | ||||
| Account.create!(id: -99, actor_type: 'Application', locked: true, username: domain) | ||||
|  | ||||
| if Rails.env.development? | ||||
|   domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain | ||||
|   admin  = Account.where(username: 'admin').first_or_initialize(username: 'admin') | ||||
|   admin.save(validate: false) | ||||
|   User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, admin: true, account: admin, agreement: true, approved: true).save! | ||||
|   | ||||
| @@ -450,7 +450,7 @@ RSpec.describe Account, type: :model do | ||||
|   describe '.domains' do | ||||
|     it 'returns domains' do | ||||
|       Fabricate(:account, domain: 'domain') | ||||
|       expect(Account.domains).to match_array(['domain']) | ||||
|       expect(Account.remote.domains).to match_array(['domain']) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -665,7 +665,7 @@ RSpec.describe Account, type: :model do | ||||
|           { username: 'b', domain: 'b' }, | ||||
|         ].map(&method(:Fabricate).curry(2).call(:account)) | ||||
|  | ||||
|         expect(Account.alphabetic).to eq matches | ||||
|         expect(Account.where('id > 0').alphabetic).to eq matches | ||||
|       end | ||||
|     end | ||||
|  | ||||
| @@ -732,7 +732,7 @@ RSpec.describe Account, type: :model do | ||||
|         2.times { Fabricate(:account, domain: 'example.com') } | ||||
|         Fabricate(:account, domain: 'example2.com') | ||||
|  | ||||
|         results = Account.by_domain_accounts | ||||
|         results = Account.where('id > 0').by_domain_accounts | ||||
|         expect(results.length).to eq 2 | ||||
|         expect(results.first.domain).to eq 'example.com' | ||||
|         expect(results.first.accounts_count).to eq 2 | ||||
| @@ -745,7 +745,7 @@ RSpec.describe Account, type: :model do | ||||
|       it 'returns an array of accounts who do not have a domain' do | ||||
|         account_1 = Fabricate(:account, domain: nil) | ||||
|         account_2 = Fabricate(:account, domain: 'example.com') | ||||
|         expect(Account.local).to match_array([account_1]) | ||||
|         expect(Account.where('id > 0').local).to match_array([account_1]) | ||||
|       end | ||||
|     end | ||||
|  | ||||
| @@ -756,14 +756,14 @@ RSpec.describe Account, type: :model do | ||||
|           matches[index] = Fabricate(:account, domain: matches[index]) | ||||
|         end | ||||
|  | ||||
|         expect(Account.partitioned).to match_array(matches) | ||||
|         expect(Account.where('id > 0').partitioned).to match_array(matches) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'recent' do | ||||
|       it 'returns a relation of accounts sorted by recent creation' do | ||||
|         matches = 2.times.map { Fabricate(:account) } | ||||
|         expect(Account.recent).to match_array(matches) | ||||
|         expect(Account.where('id > 0').recent).to match_array(matches) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,6 @@ RSpec.describe FetchRemoteAccountService, type: :service do | ||||
|   let(:url) { 'https://example.com/alice' } | ||||
|   let(:prefetched_body) { nil } | ||||
|   let(:protocol) { :ostatus } | ||||
|   let!(:representative) { Fabricate(:account) } | ||||
|  | ||||
|   subject { FetchRemoteAccountService.new.call(url, prefetched_body, protocol) } | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe FetchResourceService, type: :service do | ||||
|   let!(:representative) { Fabricate(:account) } | ||||
|  | ||||
|   describe '#call' do | ||||
|     let(:url) { 'http://example.com' } | ||||
|  | ||||
| @@ -60,7 +58,7 @@ RSpec.describe FetchResourceService, type: :service do | ||||
|  | ||||
|       it 'signs request' do | ||||
|         subject | ||||
|         expect(a_request(:get, url).with(headers: { 'Signature' => /keyId="#{Regexp.escape(ActivityPub::TagManager.instance.uri_for(representative) + '#main-key')}"/ })).to have_been_made | ||||
|         expect(a_request(:get, url).with(headers: { 'Signature' => /keyId="#{Regexp.escape(ActivityPub::TagManager.instance.uri_for(Account.representative) + '#main-key')}"/ })).to have_been_made | ||||
|       end | ||||
|  | ||||
|       context 'when content type is application/atom+xml' do | ||||
|   | ||||
| @@ -27,6 +27,7 @@ RSpec.configure do |config| | ||||
|   end | ||||
|  | ||||
|   config.before :suite do | ||||
|     Rails.application.load_seed | ||||
|     Chewy.strategy(:bypass) | ||||
|   end | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user