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 more; end
def more
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
end
def terms; end

View File

@ -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?

View File

@ -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

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%;
}
.flash-message {
margin-bottom: 10px;
}
@media screen and (max-width: 738px) {
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)
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

View File

@ -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

View File

@ -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

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? }
# 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

View File

@ -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)

View File

@ -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?

View File

@ -10,10 +10,20 @@ class WebfingerSerializer < ActiveModel::Serializer
end
def aliases
if object.instance_actor?
[instance_actor_url]
else
[short_account_url(object), account_url(object)]
end
end
def links
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') },
@ -22,3 +32,4 @@ class WebfingerSerializer < ActiveModel::Serializer
]
end
end
end

View File

@ -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')

View File

@ -4,6 +4,22 @@ 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)
if @account.instance_actor?
xrd << (Ox::Element.new('Alias') << instance_actor_url)
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'] = instance_actor_url
end
else
xrd << (Ox::Element.new('Alias') << short_account_url(@account))
xrd << (Ox::Element.new('Alias') << account_url(@account))
@ -30,5 +46,6 @@ doc << Ox::Element.new('XRD').tap do |xrd|
link['template'] = "#{authorize_interaction_url}?acct={uri}"
end
end
end
('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(doc, effort: :tolerant)).force_encoding('UTF-8')

View File

@ -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

View File

@ -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

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.
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"

View File

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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

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