Merge tag 'v4.2.5' of https://github.com/mastodon/mastodon into paravielfalt-4.2
All checks were successful
Build Image for Deployment / build (push) Successful in 8m15s
All checks were successful
Build Image for Deployment / build (push) Successful in 8m15s
This commit is contained in:
commit
8f87084a12
74 changed files with 849 additions and 518 deletions
6
.bundler-audit.yml
Normal file
6
.bundler-audit.yml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
ignore:
|
||||||
|
# devise-two-factor advisory about brute-forcing TOTP
|
||||||
|
# We have rate-limits on authentication endpoints in place (including second
|
||||||
|
# factor verification) since Mastodon v3.2.0
|
||||||
|
- CVE-2024-0227
|
|
@ -1 +1 @@
|
||||||
3.2.2
|
3.2.3
|
||||||
|
|
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -2,6 +2,38 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.2.5] - 2024-02-01
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw))
|
||||||
|
|
||||||
|
## [4.2.4] - 2024-01-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823))
|
||||||
|
- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816))
|
||||||
|
- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788))
|
||||||
|
- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748))
|
||||||
|
- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476))
|
||||||
|
- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665))
|
||||||
|
- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558))
|
||||||
|
- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252))
|
||||||
|
- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035))
|
||||||
|
- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763))
|
||||||
|
- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479))
|
||||||
|
- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127))
|
||||||
|
- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482))
|
||||||
|
- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339))
|
||||||
|
- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337))
|
||||||
|
- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268))
|
||||||
|
- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801))
|
||||||
|
|
||||||
## [4.2.3] - 2023-12-05
|
## [4.2.3] - 2023-12-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
|
# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
|
||||||
ARG NODE_VERSION="20.6-bookworm-slim"
|
ARG NODE_VERSION="20.6-bookworm-slim"
|
||||||
|
|
||||||
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby
|
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.3-slim as ruby
|
||||||
FROM node:${NODE_VERSION} as build
|
FROM node:${NODE_VERSION} as build
|
||||||
|
|
||||||
COPY --link --from=ruby /opt/ruby /opt/ruby
|
COPY --link --from=ruby /opt/ruby /opt/ruby
|
||||||
|
|
|
@ -479,7 +479,7 @@ GEM
|
||||||
net-smtp (0.3.3)
|
net-smtp (0.3.3)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.1.0)
|
net-ssh (7.1.0)
|
||||||
nio4r (2.5.9)
|
nio4r (2.7.0)
|
||||||
nokogiri (1.15.4)
|
nokogiri (1.15.4)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
|
@ -534,7 +534,7 @@ GEM
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
private_address_check (0.5.0)
|
private_address_check (0.5.0)
|
||||||
public_suffix (5.0.3)
|
public_suffix (5.0.3)
|
||||||
puma (6.3.1)
|
puma (6.4.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.0)
|
pundit (2.3.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
|
|
@ -17,6 +17,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||||
| ------- | ---------------- |
|
| ------- | ---------------- |
|
||||||
| 4.2.x | Yes |
|
| 4.2.x | Yes |
|
||||||
| 4.1.x | Yes |
|
| 4.1.x | Yes |
|
||||||
| 4.0.x | No |
|
| < 4.1 | No |
|
||||||
| 3.5.x | Until 2023-12-31 |
|
|
||||||
| < 3.5 | No |
|
|
||||||
|
|
|
@ -25,6 +25,6 @@ class Api::V1::Accounts::NotesController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def relationships_presenter
|
def relationships_presenter
|
||||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
AccountRelationshipsPresenter.new([@account], current_user.account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,6 +25,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def relationships_presenter
|
def relationships_presenter
|
||||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
AccountRelationshipsPresenter.new([@account], current_user.account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,11 +5,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
accounts = Account.without_suspended.where(id: account_ids).select('id')
|
@accounts = Account.without_suspended.where(id: account_ids).select(:id, :domain).to_a
|
||||||
# .where doesn't guarantee that our results are in the same order
|
# .where doesn't guarantee that our results are in the same order
|
||||||
# we requested them, so return the "right" order to the requestor.
|
# we requested them, so return the "right" order to the requestor.
|
||||||
@accounts = accounts.index_by(&:id).values_at(*account_ids).compact
|
render json: @accounts.index_by(&:id).values_at(*account_ids).compact, each_serializer: REST::RelationshipSerializer, relationships: relationships
|
||||||
render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -86,7 +86,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def relationships(**options)
|
def relationships(**options)
|
||||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options)
|
AccountRelationshipsPresenter.new([@account], current_user.account_id, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
|
|
|
@ -25,11 +25,11 @@ class Api::V1::FollowRequestsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def account
|
def account
|
||||||
Account.find(params[:id])
|
@account ||= Account.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def relationships(**options)
|
def relationships(**options)
|
||||||
AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, **options)
|
AccountRelationshipsPresenter.new([account], current_user.account_id, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_accounts
|
def load_accounts
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::StreamingController < Api::BaseController
|
class Api::V1::StreamingController < Api::BaseController
|
||||||
def index
|
def index
|
||||||
if Rails.configuration.x.streaming_api_base_url == request.host
|
if same_host?
|
||||||
not_found
|
not_found
|
||||||
else
|
else
|
||||||
redirect_to streaming_api_url, status: 301, allow_other_host: true
|
redirect_to streaming_api_url, status: 301, allow_other_host: true
|
||||||
|
@ -11,9 +11,16 @@ class Api::V1::StreamingController < Api::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def same_host?
|
||||||
|
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
|
||||||
|
request.host == base_url.host && request.port == (base_url.port || 80)
|
||||||
|
end
|
||||||
|
|
||||||
def streaming_api_url
|
def streaming_api_url
|
||||||
Addressable::URI.parse(request.url).tap do |uri|
|
Addressable::URI.parse(request.url).tap do |uri|
|
||||||
uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host
|
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
|
||||||
|
uri.host = base_url.host
|
||||||
|
uri.port = base_url.port
|
||||||
end.to_s
|
end.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Auth::SessionsController < Devise::SessionsController
|
class Auth::SessionsController < Devise::SessionsController
|
||||||
|
include Redisable
|
||||||
|
|
||||||
|
MAX_2FA_ATTEMPTS_PER_HOUR = 10
|
||||||
|
|
||||||
layout 'auth'
|
layout 'auth'
|
||||||
|
|
||||||
skip_before_action :require_no_authentication, only: [:create]
|
skip_before_action :require_no_authentication, only: [:create]
|
||||||
|
@ -134,9 +138,23 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
session.delete(:attempt_user_updated_at)
|
session.delete(:attempt_user_updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def clear_2fa_attempt_from_user(user)
|
||||||
|
redis.del(second_factor_attempts_key(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_second_factor_rate_limits(user)
|
||||||
|
attempts, = redis.multi do |multi|
|
||||||
|
multi.incr(second_factor_attempts_key(user))
|
||||||
|
multi.expire(second_factor_attempts_key(user), 1.hour)
|
||||||
|
end
|
||||||
|
|
||||||
|
attempts >= MAX_2FA_ATTEMPTS_PER_HOUR
|
||||||
|
end
|
||||||
|
|
||||||
def on_authentication_success(user, security_measure)
|
def on_authentication_success(user, security_measure)
|
||||||
@on_authentication_success_called = true
|
@on_authentication_success_called = true
|
||||||
|
|
||||||
|
clear_2fa_attempt_from_user(user)
|
||||||
clear_attempt_from_session
|
clear_attempt_from_session
|
||||||
|
|
||||||
user.update_sign_in!(new_sign_in: true)
|
user.update_sign_in!(new_sign_in: true)
|
||||||
|
@ -168,4 +186,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
user_agent: request.user_agent
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def second_factor_attempts_key(user)
|
||||||
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -91,14 +91,23 @@ module SignatureVerification
|
||||||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
compare_signed_string = build_signed_string
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
|
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
|
# Compatibility quirk with older Mastodon versions
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: false)
|
||||||
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
||||||
|
|
||||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
|
# Compatibility quirk with older Mastodon versions
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: false)
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||||
|
@ -180,11 +189,18 @@ module SignatureVerification
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_signed_string
|
def build_signed_string(include_query_string: true)
|
||||||
signed_headers.map do |signed_header|
|
signed_headers.map do |signed_header|
|
||||||
case signed_header
|
case signed_header
|
||||||
when Request::REQUEST_TARGET
|
when Request::REQUEST_TARGET
|
||||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
if include_query_string
|
||||||
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
|
||||||
|
else
|
||||||
|
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||||
|
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||||
|
# TODO: remove eventually some time after release of the fixed version
|
||||||
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||||
|
end
|
||||||
when '(created)'
|
when '(created)'
|
||||||
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||||
|
@ -250,7 +266,7 @@ module SignatureVerification
|
||||||
stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
|
stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
|
||||||
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
|
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
|
||||||
account = ActivityPub::TagManager.instance.uri_to_actor(key_id)
|
account = ActivityPub::TagManager.instance.uri_to_actor(key_id)
|
||||||
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
|
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) }
|
||||||
account
|
account
|
||||||
end
|
end
|
||||||
rescue Mastodon::PrivateNetworkAddressError => e
|
rescue Mastodon::PrivateNetworkAddressError => e
|
||||||
|
|
|
@ -65,6 +65,11 @@ module TwoFactorAuthenticationConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_with_two_factor_via_otp(user)
|
def authenticate_with_two_factor_via_otp(user)
|
||||||
|
if check_second_factor_rate_limits(user)
|
||||||
|
flash.now[:alert] = I18n.t('users.rate_limited')
|
||||||
|
return prompt_for_two_factor(user)
|
||||||
|
end
|
||||||
|
|
||||||
if valid_otp_attempt?(user)
|
if valid_otp_attempt?(user)
|
||||||
on_authentication_success(user, :otp)
|
on_authentication_success(user, :otp)
|
||||||
else
|
else
|
||||||
|
|
|
@ -33,7 +33,7 @@ class RelationshipsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_relationships
|
def set_relationships
|
||||||
@relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id)
|
@relationships = AccountRelationshipsPresenter.new(@accounts, current_user.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_account_batch_params
|
def form_account_batch_params
|
||||||
|
|
|
@ -155,8 +155,8 @@ module JsonLdHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_resource(uri, id, on_behalf_of = nil)
|
def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
|
||||||
unless id
|
unless id_is_known
|
||||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
||||||
|
|
||||||
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
|
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
|
||||||
|
@ -164,14 +164,14 @@ module JsonLdHelper
|
||||||
uri = json['id']
|
uri = json['id']
|
||||||
end
|
end
|
||||||
|
|
||||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options)
|
||||||
json.present? && json['id'] == uri ? json : nil
|
json.present? && json['id'] == uri ? json : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
|
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {})
|
||||||
on_behalf_of ||= Account.representative
|
on_behalf_of ||= Account.representative
|
||||||
|
|
||||||
build_request(uri, on_behalf_of).perform do |response|
|
build_request(uri, on_behalf_of, options: request_options).perform do |response|
|
||||||
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
|
||||||
|
|
||||||
body_to_json(response.body_with_limit) if response.code == 200
|
body_to_json(response.body_with_limit) if response.code == 200
|
||||||
|
@ -204,8 +204,8 @@ module JsonLdHelper
|
||||||
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_request(uri, on_behalf_of = nil)
|
def build_request(uri, on_behalf_of = nil, options: {})
|
||||||
Request.new(:get, uri).tap do |request|
|
Request.new(:get, uri, **options).tap do |request|
|
||||||
request.on_behalf_of(on_behalf_of) if on_behalf_of
|
request.on_behalf_of(on_behalf_of) if on_behalf_of
|
||||||
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { PureComponent } from 'react';
|
||||||
const iconStyle = {
|
const iconStyle = {
|
||||||
height: null,
|
height: null,
|
||||||
lineHeight: '27px',
|
lineHeight: '27px',
|
||||||
width: `${18 * 1.28571429}px`,
|
minWidth: `${18 * 1.28571429}px`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class TextIconButton extends PureComponent {
|
export default class TextIconButton extends PureComponent {
|
||||||
|
|
|
@ -45,24 +45,20 @@ class Statuses extends PureComponent {
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
|
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<StatusList
|
||||||
<DismissableBanner id='explore/statuses'>
|
trackScroll
|
||||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' />
|
prepend={<DismissableBanner id='explore/statuses'><FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' /></DismissableBanner>}
|
||||||
</DismissableBanner>
|
alwaysPrepend
|
||||||
|
timelineId='explore'
|
||||||
<StatusList
|
statusIds={statusIds}
|
||||||
trackScroll
|
scrollKey='explore-statuses'
|
||||||
timelineId='explore'
|
hasMore={hasMore}
|
||||||
statusIds={statusIds}
|
isLoading={isLoading}
|
||||||
scrollKey='explore-statuses'
|
onLoadMore={this.handleLoadMore}
|
||||||
hasMore={hasMore}
|
emptyMessage={emptyMessage}
|
||||||
isLoading={isLoading}
|
bindToDocument={!multiColumn}
|
||||||
onLoadMore={this.handleLoadMore}
|
withCounters
|
||||||
emptyMessage={emptyMessage}
|
/>
|
||||||
bindToDocument={!multiColumn}
|
|
||||||
withCounters
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -201,7 +201,7 @@ class ListTimeline extends PureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='setting-toggle'>
|
<div className='setting-toggle'>
|
||||||
<Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
|
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
|
||||||
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
|
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
|
||||||
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
|
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -284,6 +284,7 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
line-height: 27px;
|
line-height: 27px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
|
@ -4493,11 +4494,6 @@ a.status-card {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
@supports (display: grid) {
|
|
||||||
// hack to fix Chrome <57
|
|
||||||
contain: strict;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > span {
|
& > span {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,7 +154,7 @@ class ActivityPub::Activity
|
||||||
if object_uri.start_with?('http')
|
if object_uri.start_with?('http')
|
||||||
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
||||||
|
|
||||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||||
elsif @object['url'].present?
|
elsif @object['url'].present?
|
||||||
::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
|
::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature
|
||||||
return unless type == 'RsaSignature2017'
|
return unless type == 'RsaSignature2017'
|
||||||
|
|
||||||
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
|
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
|
||||||
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
|
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
|
||||||
|
|
||||||
return if creator.nil?
|
return if creator.nil?
|
||||||
|
|
||||||
|
|
|
@ -37,13 +37,13 @@ class InlineRenderer
|
||||||
private
|
private
|
||||||
|
|
||||||
def preload_associations_for_status
|
def preload_associations_for_status
|
||||||
ActiveRecord::Associations::Preloader.new(records: @object, associations: {
|
ActiveRecord::Associations::Preloader.new(records: [@object], associations: {
|
||||||
active_mentions: :account,
|
active_mentions: :account,
|
||||||
|
|
||||||
reblog: {
|
reblog: {
|
||||||
active_mentions: :account,
|
active_mentions: :account,
|
||||||
},
|
},
|
||||||
})
|
}).call
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
|
|
|
@ -37,6 +37,7 @@ class LinkDetailsExtractor
|
||||||
|
|
||||||
def language
|
def language
|
||||||
lang = json['inLanguage']
|
lang = json['inLanguage']
|
||||||
|
lang = lang.first if lang.is_a?(Array)
|
||||||
lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
|
lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -77,6 +77,7 @@ class Request
|
||||||
@url = Addressable::URI.parse(url).normalize
|
@url = Addressable::URI.parse(url).normalize
|
||||||
@http_client = options.delete(:http_client)
|
@http_client = options.delete(:http_client)
|
||||||
@allow_local = options.delete(:allow_local)
|
@allow_local = options.delete(:allow_local)
|
||||||
|
@full_path = options.delete(:with_query_string)
|
||||||
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
||||||
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
||||||
@options = @options.merge(proxy_url) if use_proxy?
|
@options = @options.merge(proxy_url) if use_proxy?
|
||||||
|
@ -146,7 +147,7 @@ class Request
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_common_headers!
|
def set_common_headers!
|
||||||
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
@headers[REQUEST_TARGET] = request_target
|
||||||
@headers['User-Agent'] = Mastodon::Version.user_agent
|
@headers['User-Agent'] = Mastodon::Version.user_agent
|
||||||
@headers['Host'] = @url.host
|
@headers['Host'] = @url.host
|
||||||
@headers['Date'] = Time.now.utc.httpdate
|
@headers['Date'] = Time.now.utc.httpdate
|
||||||
|
@ -157,6 +158,14 @@ class Request
|
||||||
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_target
|
||||||
|
if @url.query.nil? || !@full_path
|
||||||
|
"#{@verb} #{@url.path}"
|
||||||
|
else
|
||||||
|
"#{@verb} #{@url.path}?#{@url.query}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def signature
|
def signature
|
||||||
algorithm = 'rsa-sha256'
|
algorithm = 'rsa-sha256'
|
||||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||||
|
|
|
@ -16,28 +16,28 @@ class StatusReachFinder
|
||||||
private
|
private
|
||||||
|
|
||||||
def reached_account_inboxes
|
def reached_account_inboxes
|
||||||
|
Account.where(id: reached_account_ids).inboxes
|
||||||
|
end
|
||||||
|
|
||||||
|
def reached_account_ids
|
||||||
# When the status is a reblog, there are no interactions with it
|
# When the status is a reblog, there are no interactions with it
|
||||||
# directly, we assume all interactions are with the original one
|
# directly, we assume all interactions are with the original one
|
||||||
|
|
||||||
if @status.reblog?
|
if @status.reblog?
|
||||||
[]
|
[reblog_of_account_id]
|
||||||
else
|
else
|
||||||
Account.where(id: reached_account_ids).inboxes
|
[
|
||||||
end
|
replied_to_account_id,
|
||||||
end
|
reblog_of_account_id,
|
||||||
|
mentioned_account_ids,
|
||||||
def reached_account_ids
|
reblogs_account_ids,
|
||||||
[
|
favourites_account_ids,
|
||||||
replied_to_account_id,
|
replies_account_ids,
|
||||||
reblog_of_account_id,
|
].tap do |arr|
|
||||||
mentioned_account_ids,
|
arr.flatten!
|
||||||
reblogs_account_ids,
|
arr.compact!
|
||||||
favourites_account_ids,
|
arr.uniq!
|
||||||
replies_account_ids,
|
end
|
||||||
].tap do |arr|
|
|
||||||
arr.flatten!
|
|
||||||
arr.compact!
|
|
||||||
arr.uniq!
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -18,16 +18,12 @@ class AccountDomainBlock < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
|
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
|
||||||
|
|
||||||
after_commit :remove_blocking_cache
|
after_commit :invalidate_domain_blocking_cache
|
||||||
after_commit :remove_relationship_cache
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def remove_blocking_cache
|
def invalidate_domain_blocking_cache
|
||||||
Rails.cache.delete("exclude_domains_for:#{account_id}")
|
Rails.cache.delete("exclude_domains_for:#{account_id}")
|
||||||
end
|
Rails.cache.delete(['exclude_domains', account_id, domain])
|
||||||
|
|
||||||
def remove_relationship_cache
|
|
||||||
Rails.cache.delete_matched("relationship:#{account_id}:*")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -78,9 +78,9 @@ class Announcement < ApplicationRecord
|
||||||
else
|
else
|
||||||
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
|
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
|
||||||
end
|
end
|
||||||
end
|
end.to_a
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji).call
|
||||||
records
|
records
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -60,12 +60,6 @@ module AccountInteractions
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def domain_blocking_map(target_account_ids, account_id)
|
|
||||||
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
|
|
||||||
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
|
|
||||||
accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def domain_blocking_map_by_domain(target_domains, account_id)
|
def domain_blocking_map_by_domain(target_domains, account_id)
|
||||||
follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
|
follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
|
||||||
end
|
end
|
||||||
|
|
|
@ -122,7 +122,7 @@ module AccountSearch
|
||||||
tsquery = generate_query_for_search(terms)
|
tsquery = generate_query_for_search(terms)
|
||||||
|
|
||||||
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ module AccountSearch
|
||||||
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
||||||
|
|
||||||
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ module RelationshipCacheable
|
||||||
private
|
private
|
||||||
|
|
||||||
def remove_relationship_cache
|
def remove_relationship_cache
|
||||||
Rails.cache.delete("relationship:#{account_id}:#{target_account_id}")
|
Rails.cache.delete(['relationship', account_id, target_account_id])
|
||||||
Rails.cache.delete("relationship:#{target_account_id}:#{account_id}")
|
Rails.cache.delete(['relationship', target_account_id, account_id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -111,7 +111,7 @@ class Notification < ApplicationRecord
|
||||||
|
|
||||||
# Instead of using the usual `includes`, manually preload each type.
|
# Instead of using the usual `includes`, manually preload each type.
|
||||||
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
|
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
|
||||||
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations)
|
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations).call
|
||||||
end
|
end
|
||||||
|
|
||||||
unique_target_statuses = notifications.filter_map(&:target_status).uniq
|
unique_target_statuses = notifications.filter_map(&:target_status).uniq
|
||||||
|
|
|
@ -96,11 +96,9 @@ class User < ApplicationRecord
|
||||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
|
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
|
||||||
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
||||||
|
|
||||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
|
||||||
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
||||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||||
validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map { |tz| tz.tzinfo.name } }, allow_blank: true
|
|
||||||
|
|
||||||
# Honeypot/anti-spam fields
|
# Honeypot/anti-spam fields
|
||||||
attr_accessor :registration_form_time, :website, :confirm_password
|
attr_accessor :registration_form_time, :website, :confirm_password
|
||||||
|
@ -124,6 +122,8 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
before_validation :sanitize_languages
|
before_validation :sanitize_languages
|
||||||
before_validation :sanitize_role
|
before_validation :sanitize_role
|
||||||
|
before_validation :sanitize_time_zone
|
||||||
|
before_validation :sanitize_locale
|
||||||
before_create :set_approved
|
before_create :set_approved
|
||||||
after_commit :send_pending_devise_notifications
|
after_commit :send_pending_devise_notifications
|
||||||
after_create_commit :trigger_webhooks
|
after_create_commit :trigger_webhooks
|
||||||
|
@ -451,9 +451,15 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitize_role
|
def sanitize_role
|
||||||
return if role.nil?
|
self.role = nil if role.present? && role.everyone?
|
||||||
|
end
|
||||||
|
|
||||||
self.role = nil if role.everyone?
|
def sanitize_time_zone
|
||||||
|
self.time_zone = nil if time_zone.present? && ActiveSupport::TimeZone[time_zone].nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_locale
|
||||||
|
self.locale = nil if locale.present? && I18n.available_locales.exclude?(locale.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_new_user!
|
def prepare_new_user!
|
||||||
|
|
|
@ -5,8 +5,9 @@ class AccountRelationshipsPresenter
|
||||||
:muting, :requested, :requested_by, :domain_blocking,
|
:muting, :requested, :requested_by, :domain_blocking,
|
||||||
:endorsed, :account_note
|
:endorsed, :account_note
|
||||||
|
|
||||||
def initialize(account_ids, current_account_id, **options)
|
def initialize(accounts, current_account_id, **options)
|
||||||
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
|
@accounts = accounts.to_a
|
||||||
|
@account_ids = @accounts.pluck(:id)
|
||||||
@current_account_id = current_account_id
|
@current_account_id = current_account_id
|
||||||
|
|
||||||
@following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id))
|
@following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id))
|
||||||
|
@ -16,10 +17,11 @@ class AccountRelationshipsPresenter
|
||||||
@muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
|
@muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
|
||||||
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
|
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
|
||||||
@requested_by = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id))
|
@requested_by = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id))
|
||||||
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
|
|
||||||
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
|
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
|
||||||
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
|
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
|
||||||
|
|
||||||
|
@domain_blocking = domain_blocking_map
|
||||||
|
|
||||||
cache_uncached!
|
cache_uncached!
|
||||||
|
|
||||||
@following.merge!(options[:following_map] || {})
|
@following.merge!(options[:following_map] || {})
|
||||||
|
@ -36,6 +38,31 @@ class AccountRelationshipsPresenter
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def domain_blocking_map
|
||||||
|
target_domains = @accounts.pluck(:domain).compact.uniq
|
||||||
|
blocks_by_domain = {}
|
||||||
|
|
||||||
|
# Fetch from cache
|
||||||
|
cache_keys = target_domains.map { |domain| domain_cache_key(domain) }
|
||||||
|
Rails.cache.read_multi(*cache_keys).each do |key, blocking|
|
||||||
|
blocks_by_domain[key.last] = blocking
|
||||||
|
end
|
||||||
|
|
||||||
|
uncached_domains = target_domains - blocks_by_domain.keys
|
||||||
|
|
||||||
|
# Read uncached values from database
|
||||||
|
AccountDomainBlock.where(account_id: @current_account_id, domain: uncached_domains).pluck(:domain).each do |domain|
|
||||||
|
blocks_by_domain[domain] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Write database reads to cache
|
||||||
|
to_cache = uncached_domains.to_h { |domain| [domain_cache_key(domain), blocks_by_domain[domain]] }
|
||||||
|
Rails.cache.write_multi(to_cache, expires_in: 1.day)
|
||||||
|
|
||||||
|
# Return formatted value
|
||||||
|
@accounts.each_with_object({}) { |account, h| h[account.id] = blocks_by_domain[account.domain] }
|
||||||
|
end
|
||||||
|
|
||||||
def cached
|
def cached
|
||||||
return @cached if defined?(@cached)
|
return @cached if defined?(@cached)
|
||||||
|
|
||||||
|
@ -47,28 +74,23 @@ class AccountRelationshipsPresenter
|
||||||
muting: {},
|
muting: {},
|
||||||
requested: {},
|
requested: {},
|
||||||
requested_by: {},
|
requested_by: {},
|
||||||
domain_blocking: {},
|
|
||||||
endorsed: {},
|
endorsed: {},
|
||||||
account_note: {},
|
account_note: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@uncached_account_ids = []
|
@uncached_account_ids = @account_ids.uniq
|
||||||
|
|
||||||
@account_ids.each do |account_id|
|
cache_ids = @account_ids.map { |account_id| relationship_cache_key(account_id) }
|
||||||
maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
|
Rails.cache.read_multi(*cache_ids).each do |key, maps_for_account|
|
||||||
|
@cached.deep_merge!(maps_for_account)
|
||||||
if maps_for_account.is_a?(Hash)
|
@uncached_account_ids.delete(key.last)
|
||||||
@cached.deep_merge!(maps_for_account)
|
|
||||||
else
|
|
||||||
@uncached_account_ids << account_id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@cached
|
@cached
|
||||||
end
|
end
|
||||||
|
|
||||||
def cache_uncached!
|
def cache_uncached!
|
||||||
@uncached_account_ids.each do |account_id|
|
to_cache = @uncached_account_ids.to_h do |account_id|
|
||||||
maps_for_account = {
|
maps_for_account = {
|
||||||
following: { account_id => following[account_id] },
|
following: { account_id => following[account_id] },
|
||||||
followed_by: { account_id => followed_by[account_id] },
|
followed_by: { account_id => followed_by[account_id] },
|
||||||
|
@ -77,12 +99,21 @@ class AccountRelationshipsPresenter
|
||||||
muting: { account_id => muting[account_id] },
|
muting: { account_id => muting[account_id] },
|
||||||
requested: { account_id => requested[account_id] },
|
requested: { account_id => requested[account_id] },
|
||||||
requested_by: { account_id => requested_by[account_id] },
|
requested_by: { account_id => requested_by[account_id] },
|
||||||
domain_blocking: { account_id => domain_blocking[account_id] },
|
|
||||||
endorsed: { account_id => endorsed[account_id] },
|
endorsed: { account_id => endorsed[account_id] },
|
||||||
account_note: { account_id => account_note[account_id] },
|
account_note: { account_id => account_note[account_id] },
|
||||||
}
|
}
|
||||||
|
|
||||||
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
|
[relationship_cache_key(account_id), maps_for_account]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rails.cache.write_multi(to_cache, expires_in: 1.day)
|
||||||
|
end
|
||||||
|
|
||||||
|
def domain_cache_key(domain)
|
||||||
|
['exclude_domains', @current_account_id, domain]
|
||||||
|
end
|
||||||
|
|
||||||
|
def relationship_cache_key(account_id)
|
||||||
|
['relationship', @current_account_id, account_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -86,8 +86,8 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new(
|
ActiveRecord::Associations::Preloader.new(
|
||||||
records: [object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact,
|
records: [object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact,
|
||||||
associations: [:account_stat, :user, { moved_to_account: [:account_stat, :user] }]
|
associations: [:account_stat, { user: :role, moved_to_account: [:account_stat, { user: :role }] }]
|
||||||
)
|
).call
|
||||||
|
|
||||||
store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
|
store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
|
||||||
store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
|
store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin
|
||||||
|
|
|
@ -218,7 +218,7 @@ class AccountSearchService < BaseService
|
||||||
|
|
||||||
records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact
|
records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact
|
||||||
|
|
||||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
|
||||||
|
|
||||||
records
|
records
|
||||||
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
||||||
|
|
|
@ -23,9 +23,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
|
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
|
||||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||||
actor = super
|
actor = super
|
||||||
return actor if actor.nil? || actor.is_a?(Account)
|
return actor if actor.nil? || actor.is_a?(Account)
|
||||||
|
|
||||||
|
|
|
@ -10,15 +10,15 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
||||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||||
|
|
||||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||||
return if domain_not_allowed?(uri)
|
return if domain_not_allowed?(uri)
|
||||||
return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
|
return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
|
||||||
|
|
||||||
@json = begin
|
@json = begin
|
||||||
if prefetched_body.nil?
|
if prefetched_body.nil?
|
||||||
fetch_resource(uri, id)
|
fetch_resource(uri, true)
|
||||||
else
|
else
|
||||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
body_to_json(prefetched_body, compare_id: uri)
|
||||||
end
|
end
|
||||||
rescue Oj::ParseError
|
rescue Oj::ParseError
|
||||||
raise Error, "Error parsing JSON-LD document #{uri}"
|
raise Error, "Error parsing JSON-LD document #{uri}"
|
||||||
|
|
|
@ -6,23 +6,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
# Returns actor that owns the key
|
# Returns actor that owns the key
|
||||||
def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
|
def call(uri, suppress_errors: true)
|
||||||
raise Error, 'No key URI given' if uri.blank?
|
raise Error, 'No key URI given' if uri.blank?
|
||||||
|
|
||||||
if prefetched_body.nil?
|
@json = fetch_resource(uri, false)
|
||||||
if id
|
|
||||||
@json = fetch_resource_without_id_validation(uri)
|
|
||||||
if actor_type?
|
|
||||||
@json = fetch_resource(@json['id'], true)
|
|
||||||
elsif uri != @json['id']
|
|
||||||
raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
|
|
||||||
end
|
|
||||||
else
|
|
||||||
@json = fetch_resource(uri, id)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
@json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
|
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
|
||||||
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
|
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
|
||||||
|
|
|
@ -8,14 +8,14 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
DISCOVERIES_PER_REQUEST = 1000
|
DISCOVERIES_PER_REQUEST = 1000
|
||||||
|
|
||||||
# Should be called when uri has already been checked for locality
|
# Should be called when uri has already been checked for locality
|
||||||
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
|
def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
|
||||||
return if domain_not_allowed?(uri)
|
return if domain_not_allowed?(uri)
|
||||||
|
|
||||||
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
|
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
|
||||||
@json = if prefetched_body.nil?
|
@json = if prefetched_body.nil?
|
||||||
fetch_resource(uri, id, on_behalf_of)
|
fetch_resource(uri, true, on_behalf_of)
|
||||||
else
|
else
|
||||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
body_to_json(prefetched_body, compare_id: uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless supported_context?
|
return unless supported_context?
|
||||||
|
@ -65,7 +65,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
||||||
|
|
||||||
def account_from_uri(uri)
|
def account_from_uri(uri)
|
||||||
actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||||
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
|
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
|
||||||
actor
|
actor
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -37,7 +37,20 @@ class ActivityPub::FetchRepliesService < BaseService
|
||||||
return unless @allow_synchronous_requests
|
return unless @allow_synchronous_requests
|
||||||
return if non_matching_uri_hosts?(@account.uri, collection_or_uri)
|
return if non_matching_uri_hosts?(@account.uri, collection_or_uri)
|
||||||
|
|
||||||
fetch_resource_without_id_validation(collection_or_uri, nil, true)
|
# NOTE: For backward compatibility reasons, Mastodon signs outgoing
|
||||||
|
# queries incorrectly by default.
|
||||||
|
#
|
||||||
|
# While this is relevant for all URLs with query strings, this is
|
||||||
|
# the only code path where this happens in practice.
|
||||||
|
#
|
||||||
|
# Therefore, retry with correct signatures if this fails.
|
||||||
|
begin
|
||||||
|
fetch_resource_without_id_validation(collection_or_uri, nil, true)
|
||||||
|
rescue Mastodon::UnexpectedResponseError => e
|
||||||
|
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
|
||||||
|
|
||||||
|
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filtered_replies
|
def filtered_replies
|
||||||
|
|
|
@ -277,7 +277,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
|
|
||||||
def moved_account
|
def moved_account
|
||||||
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
||||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
|
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
|
||||||
account
|
account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -59,9 +59,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ class BatchedRemoveStatusService < BaseService
|
||||||
ActiveRecord::Associations::Preloader.new(
|
ActiveRecord::Associations::Preloader.new(
|
||||||
records: statuses,
|
records: statuses,
|
||||||
associations: options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account]
|
associations: options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account]
|
||||||
)
|
).call
|
||||||
|
|
||||||
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
|
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ class BatchedRemoveStatusService < BaseService
|
||||||
ActiveRecord::Associations::Preloader.new(
|
ActiveRecord::Associations::Preloader.new(
|
||||||
records: statuses_with_account_conversations,
|
records: statuses_with_account_conversations,
|
||||||
associations: [mentions: :account]
|
associations: [mentions: :account]
|
||||||
)
|
).call
|
||||||
|
|
||||||
statuses_with_account_conversations.each(&:unlink_from_conversations!)
|
statuses_with_account_conversations.each(&:unlink_from_conversations!)
|
||||||
|
|
||||||
|
|
|
@ -100,7 +100,7 @@ class FetchOEmbedService
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate(oembed)
|
def validate(oembed)
|
||||||
oembed if oembed[:version].to_s == '1.0' && oembed[:type].present?
|
oembed if oembed.present? && oembed[:version].to_s == '1.0' && oembed[:type].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def html
|
def html
|
||||||
|
|
|
@ -48,7 +48,15 @@ class FetchResourceService < BaseService
|
||||||
body = response.body_with_limit
|
body = response.body_with_limit
|
||||||
json = body_to_json(body)
|
json = body_to_json(body)
|
||||||
|
|
||||||
[json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
|
return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
|
||||||
|
|
||||||
|
if json['id'] != @url
|
||||||
|
return if terminal
|
||||||
|
|
||||||
|
return process(json['id'], terminal: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
[@url, { prefetched_body: body }]
|
||||||
elsif !terminal
|
elsif !terminal
|
||||||
link_header = response['Link'] && parse_link_header(response)
|
link_header = response['Link'] && parse_link_header(response)
|
||||||
|
|
||||||
|
|
|
@ -69,7 +69,7 @@ class Keys::QueryService < BaseService
|
||||||
|
|
||||||
return if json['items'].blank?
|
return if json['items'].blank?
|
||||||
|
|
||||||
@devices = json['items'].map do |device|
|
@devices = as_array(json['items']).map do |device|
|
||||||
Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
|
Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
|
||||||
end
|
end
|
||||||
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
|
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
|
||||||
|
|
|
@ -43,11 +43,7 @@ class ReblogService < BaseService
|
||||||
def create_notification(reblog)
|
def create_notification(reblog)
|
||||||
reblogged_status = reblog.reblog
|
reblogged_status = reblog.reblog
|
||||||
|
|
||||||
if reblogged_status.account.local?
|
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog') if reblogged_status.account.local?
|
||||||
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog')
|
|
||||||
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
|
|
||||||
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def bump_potential_friendship(account, reblog)
|
def bump_potential_friendship(account, reblog)
|
||||||
|
|
|
@ -7,7 +7,7 @@ class LinkCrawlWorker
|
||||||
|
|
||||||
def perform(status_id)
|
def perform(status_id)
|
||||||
FetchLinkCardService.new.call(Status.find(status_id))
|
FetchLinkCardService.new.call(Status.find(status_id))
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1826,6 +1826,7 @@ en:
|
||||||
go_to_sso_account_settings: Go to your identity provider's account settings
|
go_to_sso_account_settings: Go to your identity provider's account settings
|
||||||
invalid_otp_token: Invalid two-factor code
|
invalid_otp_token: Invalid two-factor code
|
||||||
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
|
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
|
||||||
|
rate_limited: Too many authentication attempts, try again later.
|
||||||
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
|
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
|
||||||
signed_in_as: 'Signed in as:'
|
signed_in_as: 'Signed in as:'
|
||||||
verification:
|
verification:
|
||||||
|
|
|
@ -56,7 +56,7 @@ services:
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.2.3
|
image: ghcr.io/mastodon/mastodon:v4.2.5
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||||
|
@ -77,7 +77,7 @@ services:
|
||||||
|
|
||||||
streaming:
|
streaming:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.2.3
|
image: ghcr.io/mastodon/mastodon:v4.2.5
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming
|
command: node ./streaming
|
||||||
|
@ -95,7 +95,7 @@ services:
|
||||||
|
|
||||||
sidekiq:
|
sidekiq:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.2.3
|
image: ghcr.io/mastodon/mastodon:v4.2.5
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
3
|
5
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_prerelease
|
def default_prerelease
|
||||||
|
|
|
@ -16,7 +16,7 @@ module Paperclip
|
||||||
private
|
private
|
||||||
|
|
||||||
def cache_current_values
|
def cache_current_values
|
||||||
@original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
|
@original_filename = truncated_filename
|
||||||
@tempfile = copy_to_tempfile(@target)
|
@tempfile = copy_to_tempfile(@target)
|
||||||
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
||||||
@size = File.size(@tempfile)
|
@size = File.size(@tempfile)
|
||||||
|
@ -43,6 +43,13 @@ module Paperclip
|
||||||
source.response.connection.close
|
source.response.connection.close
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def truncated_filename
|
||||||
|
filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
|
||||||
|
extension = File.extname(filename)
|
||||||
|
basename = File.basename(filename, extension)
|
||||||
|
[basename[...20], extension[..4]].compact_blank.join
|
||||||
|
end
|
||||||
|
|
||||||
def filename_from_content_disposition
|
def filename_from_content_disposition
|
||||||
disposition = @target.response.headers['content-disposition']
|
disposition = @target.response.headers['content-disposition']
|
||||||
disposition&.match(/filename="([^"]*)"/)&.captures&.first
|
disposition&.match(/filename="([^"]*)"/)&.captures&.first
|
||||||
|
|
|
@ -17,7 +17,7 @@ namespace :db do
|
||||||
|
|
||||||
task :pre_migration_check do
|
task :pre_migration_check do
|
||||||
version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
|
version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
|
||||||
abort 'This version of Mastodon requires PostgreSQL 9.5 or newer. Please update PostgreSQL before updating Mastodon' if version < 90_500
|
abort 'This version of Mastodon requires PostgreSQL 10.0 or newer. Please update PostgreSQL before updating Mastodon' if version < 100_000
|
||||||
end
|
end
|
||||||
|
|
||||||
Rake::Task['db:migrate'].enhance(['db:pre_migration_check'])
|
Rake::Task['db:migrate'].enhance(['db:pre_migration_check'])
|
||||||
|
|
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
||||||
describe Api::V1::StreamingController do
|
describe Api::V1::StreamingController do
|
||||||
around(:each) do |example|
|
around(:each) do |example|
|
||||||
before = Rails.configuration.x.streaming_api_base_url
|
before = Rails.configuration.x.streaming_api_base_url
|
||||||
Rails.configuration.x.streaming_api_base_url = Rails.configuration.x.web_domain
|
Rails.configuration.x.streaming_api_base_url = "wss://#{Rails.configuration.x.web_domain}"
|
||||||
example.run
|
example.run
|
||||||
Rails.configuration.x.streaming_api_base_url = before
|
Rails.configuration.x.streaming_api_base_url = before
|
||||||
end
|
end
|
||||||
|
|
|
@ -263,6 +263,26 @@ RSpec.describe Auth::SessionsController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when repeatedly using an invalid TOTP code before using a valid code' do
|
||||||
|
before do
|
||||||
|
stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not log the user in' do
|
||||||
|
# Travel to the beginning of an hour to avoid crossing rate-limit buckets
|
||||||
|
travel_to '2023-12-20T10:00:00Z'
|
||||||
|
|
||||||
|
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
|
||||||
|
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
expect(flash[:alert]).to match I18n.t('users.rate_limited')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when using a valid OTP' do
|
context 'when using a valid OTP' do
|
||||||
before do
|
before do
|
||||||
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
|
|
|
@ -1,305 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
describe SignatureVerification do
|
|
||||||
let(:wrapped_actor_class) do
|
|
||||||
Class.new do
|
|
||||||
attr_reader :wrapped_account
|
|
||||||
|
|
||||||
def initialize(wrapped_account)
|
|
||||||
@wrapped_account = wrapped_account
|
|
||||||
end
|
|
||||||
|
|
||||||
delegate :uri, :keypair, to: :wrapped_account
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
controller(ApplicationController) do
|
|
||||||
include SignatureVerification
|
|
||||||
|
|
||||||
before_action :require_actor_signature!, only: [:signature_required]
|
|
||||||
|
|
||||||
def success
|
|
||||||
head 200
|
|
||||||
end
|
|
||||||
|
|
||||||
def alternative_success
|
|
||||||
head 200
|
|
||||||
end
|
|
||||||
|
|
||||||
def signature_required
|
|
||||||
head 200
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
routes.draw do
|
|
||||||
match :via => [:get, :post], 'success' => 'anonymous#success'
|
|
||||||
match :via => [:get, :post], 'signature_required' => 'anonymous#signature_required'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'without signature header' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns false' do
|
|
||||||
expect(controller.signed_request?).to be false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with signature header' do
|
|
||||||
let!(:author) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') }
|
|
||||||
|
|
||||||
context 'without body' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
|
|
||||||
fake_request = Request.new(:get, request.url)
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns an account' do
|
|
||||||
expect(controller.signed_request_account).to eq author
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns nil when path does not match' do
|
|
||||||
request.path = '/alternative-path'
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns nil when method does not match' do
|
|
||||||
post :success
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with a valid actor that is not an Account' do
|
|
||||||
let(:actor) { wrapped_actor_class.new(author) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
|
|
||||||
fake_request = Request.new(:get, request.url)
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
|
|
||||||
allow(ActivityPub::TagManager.instance).to receive(:uri_to_actor).with(anything) do
|
|
||||||
actor
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_actor' do
|
|
||||||
it 'returns the expected actor' do
|
|
||||||
expect(controller.signed_request_actor).to eq actor
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with request with unparsable Date header' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
|
|
||||||
fake_request = Request.new(:get, request.url)
|
|
||||||
fake_request.add_headers({ 'Date' => 'wrong date' })
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signature_verification_failure_reason' do
|
|
||||||
it 'contains an error description' do
|
|
||||||
controller.signed_request_account
|
|
||||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with request older than a day' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
|
|
||||||
fake_request = Request.new(:get, request.url)
|
|
||||||
fake_request.add_headers({ 'Date' => 2.days.ago.utc.httpdate })
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signature_verification_failure_reason' do
|
|
||||||
it 'contains an error description' do
|
|
||||||
controller.signed_request_account
|
|
||||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with inaccessible key' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
|
|
||||||
author = Fabricate(:account, domain: 'localhost:5000', uri: 'http://localhost:5000/actor')
|
|
||||||
fake_request = Request.new(:get, request.url)
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
author.destroy
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
|
|
||||||
stub_request(:get, 'http://localhost:5000/actor#main-key').to_raise(Mastodon::HostValidationError)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with body' do
|
|
||||||
before do
|
|
||||||
allow(controller).to receive(:actor_refresh_key!).and_return(author)
|
|
||||||
post :success, body: 'Hello world'
|
|
||||||
|
|
||||||
fake_request = Request.new(:post, request.url, body: 'Hello world')
|
|
||||||
fake_request.on_behalf_of(author)
|
|
||||||
|
|
||||||
request.headers.merge!(fake_request.headers)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request?' do
|
|
||||||
it 'returns true' do
|
|
||||||
expect(controller.signed_request?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns an account' do
|
|
||||||
expect(controller.signed_request_account).to eq author
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when path does not match' do
|
|
||||||
before do
|
|
||||||
request.path = '/alternative-path'
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signature_verification_failure_reason' do
|
|
||||||
it 'contains an error description' do
|
|
||||||
controller.signed_request_account
|
|
||||||
expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
|
|
||||||
expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when method does not match' do
|
|
||||||
before do
|
|
||||||
get :success
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when body has been tampered' do
|
|
||||||
before do
|
|
||||||
post :success, body: 'doo doo doo'
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#signed_request_account' do
|
|
||||||
it 'returns nil when body has been tampered' do
|
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a signature is required' do
|
|
||||||
before do
|
|
||||||
get :signature_required
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'without signature header' do
|
|
||||||
it 'returns HTTP 401' do
|
|
||||||
expect(response).to have_http_status(401)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an error' do
|
|
||||||
expect(Oj.load(response.body)['error']).to eq 'Request not signed'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -31,11 +31,5 @@ describe Settings::Preferences::AppearanceController do
|
||||||
|
|
||||||
expect(response).to redirect_to(settings_preferences_appearance_path)
|
expect(response).to redirect_to(settings_preferences_appearance_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders show on failure' do
|
|
||||||
put :update, params: { user: { locale: 'fake option' } }
|
|
||||||
|
|
||||||
expect(response).to render_template('preferences/appearance/show')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -60,7 +60,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
||||||
|
|
||||||
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
|
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
|
||||||
|
|
||||||
allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
|
allow(service_stub).to receive(:call).with('http://example.com/alice') do
|
||||||
sender.update!(public_key: old_key)
|
sender.update!(public_key: old_key)
|
||||||
sender
|
sender
|
||||||
end
|
end
|
||||||
|
@ -68,7 +68,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
||||||
|
|
||||||
it 'fetches key and returns creator' do
|
it 'fetches key and returns creator' do
|
||||||
expect(subject.verify_actor!).to eq sender
|
expect(subject.verify_actor!).to eq sender
|
||||||
expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
|
expect(service_stub).to have_received(:call).with('http://example.com/alice').once
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -112,6 +112,14 @@ RSpec.describe ActivityPub::TagManager do
|
||||||
expect(subject.cc(status)).to include(subject.uri_for(foo))
|
expect(subject.cc(status)).to include(subject.uri_for(foo))
|
||||||
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
|
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns poster of reblogged post, if reblog' do
|
||||||
|
bob = Fabricate(:account, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/bob')
|
||||||
|
alice = Fabricate(:account, username: 'alice')
|
||||||
|
status = Fabricate(:status, visibility: :public, account: bob)
|
||||||
|
reblog = Fabricate(:status, visibility: :public, account: alice, reblog: status)
|
||||||
|
expect(subject.cc(reblog)).to include(subject.uri_for(bob))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#local_uri?' do
|
describe '#local_uri?' do
|
||||||
|
|
|
@ -27,12 +27,6 @@ RSpec.describe User do
|
||||||
expect(user).to model_have_error_on_field(:account)
|
expect(user).to model_have_error_on_field(:account)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'is invalid without a valid locale' do
|
|
||||||
user = Fabricate.build(:user, locale: 'toto')
|
|
||||||
user.valid?
|
|
||||||
expect(user).to model_have_error_on_field(:locale)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'is invalid without a valid email' do
|
it 'is invalid without a valid email' do
|
||||||
user = Fabricate.build(:user, email: 'john@')
|
user = Fabricate.build(:user, email: 'john@')
|
||||||
user.valid?
|
user.valid?
|
||||||
|
@ -45,6 +39,18 @@ RSpec.describe User do
|
||||||
expect(user.valid?).to be true
|
expect(user.valid?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'cleans out invalid locale' do
|
||||||
|
user = Fabricate.build(:user, locale: 'toto')
|
||||||
|
expect(user.valid?).to be true
|
||||||
|
expect(user.locale).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cleans out invalid timezone' do
|
||||||
|
user = Fabricate.build(:user, time_zone: 'toto')
|
||||||
|
expect(user.valid?).to be true
|
||||||
|
expect(user.time_zone).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
it 'cleans out empty string from languages' do
|
it 'cleans out empty string from languages' do
|
||||||
user = Fabricate.build(:user, chosen_languages: [''])
|
user = Fabricate.build(:user, chosen_languages: [''])
|
||||||
user.valid?
|
user.valid?
|
||||||
|
|
|
@ -5,30 +5,57 @@ require 'rails_helper'
|
||||||
RSpec.describe AccountRelationshipsPresenter do
|
RSpec.describe AccountRelationshipsPresenter do
|
||||||
describe '.initialize' do
|
describe '.initialize' do
|
||||||
before do
|
before do
|
||||||
allow(Account).to receive(:following_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:following_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:followed_by_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:followed_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:blocking_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:blocking_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:muting_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:muting_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:requested_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:requested_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:requested_by_map).with(account_ids, current_account_id).and_return(default_map)
|
allow(Account).to receive(:requested_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
|
||||||
allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:presenter) { described_class.new(account_ids, current_account_id, **options) }
|
let(:presenter) { described_class.new(accounts, current_account_id, **options) }
|
||||||
let(:current_account_id) { Fabricate(:account).id }
|
let(:current_account_id) { Fabricate(:account).id }
|
||||||
let(:account_ids) { [Fabricate(:account).id] }
|
let(:accounts) { [Fabricate(:account)] }
|
||||||
let(:default_map) { { 1 => true } }
|
let(:default_map) { { accounts[0].id => true } }
|
||||||
|
|
||||||
context 'when options are not set' do
|
context 'when options are not set' do
|
||||||
let(:options) { {} }
|
let(:options) { {} }
|
||||||
|
|
||||||
it 'sets default maps' do
|
it 'sets default maps' do
|
||||||
expect(presenter.following).to eq default_map
|
expect(presenter).to have_attributes(
|
||||||
expect(presenter.followed_by).to eq default_map
|
following: default_map,
|
||||||
expect(presenter.blocking).to eq default_map
|
followed_by: default_map,
|
||||||
expect(presenter.muting).to eq default_map
|
blocking: default_map,
|
||||||
expect(presenter.requested).to eq default_map
|
muting: default_map,
|
||||||
expect(presenter.domain_blocking).to eq default_map
|
requested: default_map,
|
||||||
|
domain_blocking: { accounts[0].id => nil }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a warm cache' do
|
||||||
|
let(:options) { {} }
|
||||||
|
|
||||||
|
before do
|
||||||
|
described_class.new(accounts, current_account_id, **options)
|
||||||
|
|
||||||
|
allow(Account).to receive(:following_map).with([], current_account_id).and_return({})
|
||||||
|
allow(Account).to receive(:followed_by_map).with([], current_account_id).and_return({})
|
||||||
|
allow(Account).to receive(:blocking_map).with([], current_account_id).and_return({})
|
||||||
|
allow(Account).to receive(:muting_map).with([], current_account_id).and_return({})
|
||||||
|
allow(Account).to receive(:requested_map).with([], current_account_id).and_return({})
|
||||||
|
allow(Account).to receive(:requested_by_map).with([], current_account_id).and_return({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets returns expected values' do
|
||||||
|
expect(presenter).to have_attributes(
|
||||||
|
following: default_map,
|
||||||
|
followed_by: default_map,
|
||||||
|
blocking: default_map,
|
||||||
|
muting: default_map,
|
||||||
|
requested: default_map,
|
||||||
|
domain_blocking: { accounts[0].id => nil }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -84,7 +111,7 @@ RSpec.describe AccountRelationshipsPresenter do
|
||||||
let(:options) { { domain_blocking_map: { 7 => true } } }
|
let(:options) { { domain_blocking_map: { 7 => true } } }
|
||||||
|
|
||||||
it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do
|
it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do
|
||||||
expect(presenter.domain_blocking).to eq default_map.merge(options[:domain_blocking_map])
|
expect(presenter.domain_blocking).to eq({ accounts[0].id => nil }.merge(options[:domain_blocking_map]))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
398
spec/requests/signature_verification_spec.rb
Normal file
398
spec/requests/signature_verification_spec.rb
Normal file
|
@ -0,0 +1,398 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe 'signature verification concern' do
|
||||||
|
before do
|
||||||
|
stub_tests_controller
|
||||||
|
|
||||||
|
# Signature checking is time-dependent, so travel to a fixed date
|
||||||
|
travel_to '2023-12-20T10:00:00Z'
|
||||||
|
end
|
||||||
|
|
||||||
|
after { Rails.application.reload_routes! }
|
||||||
|
|
||||||
|
# Include the private key so the tests can be easily adjusted and reviewed
|
||||||
|
let(:actor_keypair) do
|
||||||
|
OpenSSL::PKey.read(<<~PEM_TEXT)
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI
|
||||||
|
eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn
|
||||||
|
FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F
|
||||||
|
jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn
|
||||||
|
qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar
|
||||||
|
+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3
|
||||||
|
fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd
|
||||||
|
RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC
|
||||||
|
I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh
|
||||||
|
FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk
|
||||||
|
QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu
|
||||||
|
ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC
|
||||||
|
STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO
|
||||||
|
L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6
|
||||||
|
BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7
|
||||||
|
gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X
|
||||||
|
8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3
|
||||||
|
qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE
|
||||||
|
cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo
|
||||||
|
zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3
|
||||||
|
lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F
|
||||||
|
rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza
|
||||||
|
GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE
|
||||||
|
+JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO
|
||||||
|
4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
PEM_TEXT
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without a Signature header' do
|
||||||
|
it 'does not treat the request as signed' do
|
||||||
|
get '/activitypub/success'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: false,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Request not signed'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when a signature is required' do
|
||||||
|
it 'returns http unauthorized with appropriate error' do
|
||||||
|
get '/activitypub/signature_required'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(401)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
error: 'Request not signed'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an HTTP Signature from a known account' do
|
||||||
|
let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) }
|
||||||
|
|
||||||
|
context 'with a valid signature on a GET request' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid signature on a GET request that has a query string' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=42', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the query string is missing from the signature verification (compatibility quirk)' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=42', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with mismatching query string' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=43', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a mismatching path' do
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
get '/activitypub/alternative-path', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a mismatching method' do
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
post '/activitypub/success', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an unparsable date' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'wrong date', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'wrong date',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a request older than a day' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Signed request date outside acceptable time window'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid signature on a POST request' do
|
||||||
|
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(digest_header).to eq digest_value('Hello world')
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
|
||||||
|
|
||||||
|
post '/activitypub/success', params: 'Hello world', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Digest' => digest_header,
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the Digest of a POST request is not signed' do
|
||||||
|
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(digest_header).to eq digest_value('Hello world')
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' })
|
||||||
|
|
||||||
|
post '/activitypub/success', params: 'Hello world', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Digest' => digest_header,
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Mastodon requires the Digest header to be signed when doing a POST request'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a tampered body on a POST request' do
|
||||||
|
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(digest_header).to_not eq digest_value('Hello world!')
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
|
||||||
|
|
||||||
|
post '/activitypub/success', params: 'Hello world!', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Invalid Digest value. Computed SHA-256 digest: wFNeS+K3n/2TKRMFQ2v4iTFOSj+uwF7P/Lt98xrZ5Ro=; given: ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw='
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a tampered path in a POST request' do
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
post '/activitypub/alternative-path', params: 'Hello world', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
|
||||||
|
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an inaccessible key' do
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
get '/activitypub/success', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def stub_tests_controller
|
||||||
|
stub_const('ActivityPub::TestsController', activitypub_tests_controller)
|
||||||
|
|
||||||
|
Rails.application.routes.draw do
|
||||||
|
# NOTE: RouteSet#draw removes all routes, so we need to re-insert one
|
||||||
|
resource :instance_actor, path: 'actor', only: [:show]
|
||||||
|
|
||||||
|
match :via => [:get, :post], '/activitypub/success' => 'activitypub/tests#success'
|
||||||
|
match :via => [:get, :post], '/activitypub/alternative-path' => 'activitypub/tests#alternative_success'
|
||||||
|
match :via => [:get, :post], '/activitypub/signature_required' => 'activitypub/tests#signature_required'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def activitypub_tests_controller
|
||||||
|
Class.new(ApplicationController) do
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
|
before_action :require_actor_signature!, only: [:signature_required]
|
||||||
|
|
||||||
|
def success
|
||||||
|
render json: {
|
||||||
|
signed_request: signed_request?,
|
||||||
|
signature_actor_id: signed_request_actor&.id&.to_s,
|
||||||
|
}.merge(signature_verification_failure_reason || {})
|
||||||
|
end
|
||||||
|
|
||||||
|
alias_method :alternative_success, :success
|
||||||
|
alias_method :signature_required, :success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def digest_value(body)
|
||||||
|
"SHA-256=#{Digest::SHA256.base64digest(body)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_signature_string(keypair, key_id, request_target, headers)
|
||||||
|
algorithm = 'rsa-sha256'
|
||||||
|
signed_headers = headers.merge({ '(request-target)' => request_target })
|
||||||
|
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
|
||||||
|
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||||
|
|
||||||
|
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
|
||||||
|
end
|
||||||
|
end
|
|
@ -31,7 +31,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:status_json_pinned_unknown_unreachable) do
|
let(:status_json_pinned_unknown_reachable) do
|
||||||
{
|
{
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known))
|
stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known))
|
||||||
stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined))
|
stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined))
|
||||||
stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404)
|
stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404)
|
||||||
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_unreachable))
|
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
|
||||||
|
|
||||||
subject.call(actor, note: true, hashtag: false)
|
subject.call(actor, note: true, hashtag: false)
|
||||||
end
|
end
|
||||||
|
@ -103,6 +103,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'sets pinned posts'
|
it_behaves_like 'sets pinned posts'
|
||||||
|
|
||||||
|
context 'when there is a single item, with the array compacted away' do
|
||||||
|
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
|
||||||
|
subject.call(actor, note: true, hashtag: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets expected posts as pinned posts' do
|
||||||
|
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
|
||||||
|
'https://example.com/account/pinned/unknown-reachable'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the endpoint is a paginated Collection' do
|
context 'when the endpoint is a paginated Collection' do
|
||||||
|
@ -124,6 +139,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'sets pinned posts'
|
it_behaves_like 'sets pinned posts'
|
||||||
|
|
||||||
|
context 'when there is a single item, with the array compacted away' do
|
||||||
|
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
|
||||||
|
subject.call(actor, note: true, hashtag: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets expected posts as pinned posts' do
|
||||||
|
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
|
||||||
|
'https://example.com/account/pinned/unknown-reachable'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
let(:account) { subject.call('https://example.com/alice', id: true) }
|
let(:account) { subject.call('https://example.com/alice') }
|
||||||
|
|
||||||
shared_examples 'sets profile data' do
|
shared_examples 'sets profile data' do
|
||||||
it 'returns an account' do
|
it 'returns an account' do
|
||||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
let(:account) { subject.call('https://example.com/alice', id: true) }
|
let(:account) { subject.call('https://example.com/alice') }
|
||||||
|
|
||||||
shared_examples 'sets profile data' do
|
shared_examples 'sets profile data' do
|
||||||
it 'returns an account' do
|
it 'returns an account' do
|
||||||
|
|
|
@ -55,7 +55,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
let(:account) { subject.call(public_key_id, id: false) }
|
let(:account) { subject.call(public_key_id) }
|
||||||
|
|
||||||
context 'when the key is a sub-object from the actor' do
|
context 'when the key is a sub-object from the actor' do
|
||||||
before do
|
before do
|
||||||
|
|
|
@ -34,6 +34,18 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
context 'when the payload is a Collection with inlined replies' do
|
context 'when the payload is a Collection with inlined replies' do
|
||||||
|
context 'when there is a single reply, with the array compacted away' do
|
||||||
|
let(:items) { 'http://example.com/self-reply-1' }
|
||||||
|
|
||||||
|
it 'queues the expected worker' do
|
||||||
|
allow(FetchReplyWorker).to receive(:push_bulk)
|
||||||
|
|
||||||
|
subject.call(status, payload)
|
||||||
|
|
||||||
|
expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when passing the collection itself' do
|
context 'when passing the collection itself' do
|
||||||
it 'spawns workers for up to 5 replies on the same server' do
|
it 'spawns workers for up to 5 replies on the same server' do
|
||||||
expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
|
expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
|
||||||
|
|
|
@ -57,7 +57,7 @@ RSpec.describe FetchResourceService, type: :service do
|
||||||
|
|
||||||
let(:json) do
|
let(:json) do
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 'http://example.com/foo',
|
||||||
'@context': ActivityPub::TagManager::CONTEXT,
|
'@context': ActivityPub::TagManager::CONTEXT,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
}.to_json
|
}.to_json
|
||||||
|
@ -83,27 +83,27 @@ RSpec.describe FetchResourceService, type: :service do
|
||||||
let(:content_type) { 'application/activity+json; charset=utf-8' }
|
let(:content_type) { 'application/activity+json; charset=utf-8' }
|
||||||
let(:body) { json }
|
let(:body) { json }
|
||||||
|
|
||||||
it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
|
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when content type is ld+json with profile' do
|
context 'when content type is ld+json with profile' do
|
||||||
let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
|
let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
|
||||||
let(:body) { json }
|
let(:body) { json }
|
||||||
|
|
||||||
it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
|
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when link header is present' do
|
context 'when link header is present' do
|
||||||
let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"' } }
|
let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"' } }
|
||||||
|
|
||||||
it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
|
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when content type is text/html' do
|
context 'when content type is text/html' do
|
||||||
let(:content_type) { 'text/html' }
|
let(:content_type) { 'text/html' }
|
||||||
let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
|
let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
|
||||||
|
|
||||||
it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
|
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -86,9 +86,5 @@ RSpec.describe ReblogService, type: :service do
|
||||||
it 'distributes to followers' do
|
it 'distributes to followers' do
|
||||||
expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
|
expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sends an announce activity to the author' do
|
|
||||||
expect(a_request(:post, bob.inbox_url)).to have_been_made.once
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -110,4 +110,22 @@ RSpec.describe RemoveStatusService, type: :service do
|
||||||
)).to have_been_made.once
|
)).to have_been_made.once
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when removed status is a reblog of a non-follower' do
|
||||||
|
let!(:original_status) { Fabricate(:status, account: bill, text: 'Hello ThisIsASecret', visibility: :public) }
|
||||||
|
let!(:status) { ReblogService.new.call(alice, original_status) }
|
||||||
|
|
||||||
|
it 'sends Undo activity to followers' do
|
||||||
|
subject.call(status)
|
||||||
|
expect(a_request(:post, bill.inbox_url).with(
|
||||||
|
body: hash_including({
|
||||||
|
'type' => 'Undo',
|
||||||
|
'object' => hash_including({
|
||||||
|
'type' => 'Announce',
|
||||||
|
'object' => ActivityPub::TagManager.instance.uri_for(original_status),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)).to have_been_made.once
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do
|
||||||
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
|
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
|
||||||
body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
|
body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
|
||||||
stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
|
stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
|
||||||
|
stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns status by url' do
|
it 'returns status by url' do
|
||||||
|
|
Loading…
Reference in a new issue