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:
ThibG 2019-07-19 01:44:42 +02:00 committed by Eugen Rochko
parent 15c7478c55
commit 730c4053d6
23 changed files with 141 additions and 52 deletions

View File

@ -11,7 +11,9 @@ class AboutController < ApplicationController
def show; end 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 def terms; end

View File

@ -91,7 +91,7 @@ class ApplicationController < ActionController::Base
end end
def single_user_mode? 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 end
def use_seamless_external_login? def use_seamless_external_login?

View File

@ -58,7 +58,7 @@ class HomeController < ApplicationController
if request.path.start_with?('/web') if request.path.start_with?('/web')
new_user_session_path new_user_session_path
elsif single_user_mode? elsif single_user_mode?
short_account_path(Account.local.without_suspended.first) short_account_path(Account.local.without_suspended.where('id > 0').first)
else else
about_path about_path
end end

View 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

View File

@ -145,6 +145,10 @@
min-height: 100%; min-height: 100%;
} }
.flash-message {
margin-bottom: 10px;
}
@media screen and (max-width: 738px) { @media screen and (max-width: 738px) {
grid-template-columns: minmax(0, 50%) minmax(0, 50%); grid-template-columns: minmax(0, 50%) minmax(0, 50%);

View File

@ -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) 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) reject_follow_request!(target_account)
return return
end end

View File

@ -17,7 +17,7 @@ class ActivityPub::TagManager
case target.object_type case target.object_type
when :person when :person
short_account_url(target) target.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(target)
when :note, :comment, :activity when :note, :comment, :activity
return activity_account_status_url(target.account, target) if target.reblog? return activity_account_status_url(target.account, target) if target.reblog?
short_account_status_url(target.account, target) short_account_status_url(target.account, target)
@ -29,7 +29,7 @@ class ActivityPub::TagManager
case target.object_type case target.object_type
when :person when :person
account_url(target) target.instance_actor? ? instance_actor_url : account_url(target)
when :note, :comment, :activity when :note, :comment, :activity
return activity_account_status_url(target.account, target) if target.reblog? return activity_account_status_url(target.account, target) if target.reblog?
account_status_url(target.account, target) account_status_url(target.account, target)
@ -119,6 +119,7 @@ class ActivityPub::TagManager
def uri_to_local_id(uri, param = :id) def uri_to_local_id(uri, param = :id)
path_params = Rails.application.routes.recognize_path(uri) 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] path_params[param]
end end

View File

@ -23,11 +23,17 @@ class WebfingerResource
def username_from_url def username_from_url
if account_show_page? if account_show_page?
path_params[:username] path_params[:username]
elsif instance_actor_page?
Rails.configuration.x.local_domain
else else
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound
end end
end end
def instance_actor_page?
path_params[:controller] == 'instance_actors'
end
def account_show_page? def account_show_page?
path_params[:controller] == 'accounts' && path_params[:action] == 'show' path_params[:controller] == 'accounts' && path_params[:action] == 'show'
end end

View File

@ -77,7 +77,7 @@ class Account < ApplicationRecord
validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? } validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
# Local user validations # 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 UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates_with UnreservedUsernameValidator, 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? } 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 %w(Application Service).include? actor_type
end end
def instance_actor?
id == -99
end
alias bot bot? alias bot bot?
def bot=(val) def bot=(val)
@ -498,7 +502,7 @@ class Account < ApplicationRecord
end end
def generate_keys def generate_keys
return unless local? && !Rails.env.test? return unless local? && private_key.blank? && public_key.blank?
keypair = OpenSSL::PKey::RSA.new(2048) keypair = OpenSSL::PKey::RSA.new(2048)
self.private_key = keypair.to_pem self.private_key = keypair.to_pem

View File

@ -13,7 +13,7 @@ module AccountFinderConcern
end end
def representative def representative
find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) || Account.local.without_suspended.first Account.find(-99)
end end
def find_local(username) def find_local(username)

View File

@ -39,11 +39,17 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
delegate :moved?, to: :object delegate :moved?, to: :object
def id def id
account_url(object) object.instance_actor? ? instance_actor_url : account_url(object)
end end
def type def type
object.bot? ? 'Service' : 'Person' if object.instance_actor?
'Application'
elsif object.bot?
'Service'
else
'Person'
end
end end
def following def following
@ -55,7 +61,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
end end
def inbox def inbox
account_inbox_url(object) object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object)
end end
def outbox def outbox
@ -95,7 +101,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
end end
def url def url
short_account_url(object) object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
end end
def avatar_exists? def avatar_exists?

View File

@ -10,15 +10,26 @@ class WebfingerSerializer < ActiveModel::Serializer
end end
def aliases 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 end
def links def links
[ if object.instance_actor?
{ 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: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) },
{ rel: 'self', type: 'application/activity+json', href: account_url(object) }, { rel: 'self', type: 'application/activity+json', href: instance_actor_url },
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, ]
] 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
end end

View File

@ -43,5 +43,7 @@
= mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email
.column-3 .column-3
= render 'application/flashes'
.box-widget .box-widget
.rich-formatting= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html') .rich-formatting= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')

View File

@ -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['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'
xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s) 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| if @account.instance_actor?
link['rel'] = 'http://webfinger.net/rel/profile-page' xrd << (Ox::Element.new('Alias') << instance_actor_url)
link['type'] = 'text/html'
link['href'] = short_account_url(@account)
end
xrd << Ox::Element.new('Link').tap do |link| xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://schemas.google.com/g/2010#updates-from' link['rel'] = 'http://webfinger.net/rel/profile-page'
link['type'] = 'application/atom+xml' link['type'] = 'text/html'
link['href'] = account_url(@account, format: 'atom') link['href'] = about_more_url(instance_actor: true)
end end
xrd << Ox::Element.new('Link').tap do |link| xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'self' link['rel'] = 'self'
link['type'] = 'application/activity+json' link['type'] = 'application/activity+json'
link['href'] = account_url(@account) link['href'] = instance_actor_url
end 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| xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://ostatus.org/schema/1.0/subscribe' link['rel'] = 'http://webfinger.net/rel/profile-page'
link['template'] = "#{authorize_interaction_url}?acct={uri}" 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
end end

View File

@ -24,6 +24,9 @@ en:
generic_description: "%{domain} is one server in the network" generic_description: "%{domain} is one server in the network"
get_apps: Try a mobile app get_apps: Try a mobile app
hosted_on: Mastodon hosted on %{domain} 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 learn_more: Learn more
privacy_policy: Privacy policy privacy_policy: Privacy policy
see_whats_happening: See what's happening see_whats_happening: See what's happening

View File

@ -28,6 +28,10 @@ Rails.application.routes.draw do
get 'intent', to: 'intents#show' get 'intent', to: 'intents#show'
get 'custom.css', to: 'custom_css#show', as: :custom_css 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 devise_scope :user do
get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite 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 match '/auth/finish_signup' => 'auth/confirmations#finish_signup', via: [:get, :patch], as: :finish_signup

View 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

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"

View File

@ -1,7 +1,9 @@
Doorkeeper::Application.create!(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow') 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? if Rails.env.development?
domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain
admin = Account.where(username: 'admin').first_or_initialize(username: 'admin') admin = Account.where(username: 'admin').first_or_initialize(username: 'admin')
admin.save(validate: false) 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! 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!

View File

@ -450,7 +450,7 @@ RSpec.describe Account, type: :model do
describe '.domains' do describe '.domains' do
it 'returns domains' do it 'returns domains' do
Fabricate(:account, domain: 'domain') Fabricate(:account, domain: 'domain')
expect(Account.domains).to match_array(['domain']) expect(Account.remote.domains).to match_array(['domain'])
end end
end end
@ -665,7 +665,7 @@ RSpec.describe Account, type: :model do
{ username: 'b', domain: 'b' }, { username: 'b', domain: 'b' },
].map(&method(:Fabricate).curry(2).call(:account)) ].map(&method(:Fabricate).curry(2).call(:account))
expect(Account.alphabetic).to eq matches expect(Account.where('id > 0').alphabetic).to eq matches
end end
end end
@ -732,7 +732,7 @@ RSpec.describe Account, type: :model do
2.times { Fabricate(:account, domain: 'example.com') } 2.times { Fabricate(:account, domain: 'example.com') }
Fabricate(:account, domain: 'example2.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.length).to eq 2
expect(results.first.domain).to eq 'example.com' expect(results.first.domain).to eq 'example.com'
expect(results.first.accounts_count).to eq 2 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 it 'returns an array of accounts who do not have a domain' do
account_1 = Fabricate(:account, domain: nil) account_1 = Fabricate(:account, domain: nil)
account_2 = Fabricate(:account, domain: 'example.com') 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
end end
@ -756,14 +756,14 @@ RSpec.describe Account, type: :model do
matches[index] = Fabricate(:account, domain: matches[index]) matches[index] = Fabricate(:account, domain: matches[index])
end end
expect(Account.partitioned).to match_array(matches) expect(Account.where('id > 0').partitioned).to match_array(matches)
end end
end end
describe 'recent' do describe 'recent' do
it 'returns a relation of accounts sorted by recent creation' do it 'returns a relation of accounts sorted by recent creation' do
matches = 2.times.map { Fabricate(:account) } 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
end end

View File

@ -4,7 +4,6 @@ RSpec.describe FetchRemoteAccountService, type: :service do
let(:url) { 'https://example.com/alice' } let(:url) { 'https://example.com/alice' }
let(:prefetched_body) { nil } let(:prefetched_body) { nil }
let(:protocol) { :ostatus } let(:protocol) { :ostatus }
let!(:representative) { Fabricate(:account) }
subject { FetchRemoteAccountService.new.call(url, prefetched_body, protocol) } subject { FetchRemoteAccountService.new.call(url, prefetched_body, protocol) }

View File

@ -1,8 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe FetchResourceService, type: :service do RSpec.describe FetchResourceService, type: :service do
let!(:representative) { Fabricate(:account) }
describe '#call' do describe '#call' do
let(:url) { 'http://example.com' } let(:url) { 'http://example.com' }
@ -60,7 +58,7 @@ RSpec.describe FetchResourceService, type: :service do
it 'signs request' do it 'signs request' do
subject 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 end
context 'when content type is application/atom+xml' do context 'when content type is application/atom+xml' do

View File

@ -27,6 +27,7 @@ RSpec.configure do |config|
end end
config.before :suite do config.before :suite do
Rails.application.load_seed
Chewy.strategy(:bypass) Chewy.strategy(:bypass)
end end