Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- `app/controllers/activitypub/collections_controller.rb`:
  Conflict due to glitch-soc having to take care of local-only
  pinned toots in that controller.
  Took upstream's changes and restored the local-only special
  handling.
- `app/controllers/auth/sessions_controller.rb`:
  Minor conflicts due to the theming system, applied upstream
  changes, adapted the following two files for glitch-soc's
  theming system:
  - `app/controllers/concerns/sign_in_token_authentication_concern.rb`
  - `app/controllers/concerns/two_factor_authentication_concern.rb`
- `app/services/backup_service.rb`:
  Minor conflict due to glitch-soc having to handle local-only
  toots specially. Applied upstream changes and restored
  the local-only special handling.
- `app/views/admin/custom_emojis/index.html.haml`:
  Minor conflict due to the theming system.
- `package.json`:
  Upstream dependency updated, too close to a glitch-soc-only
  dependency in the file.
- `yarn.lock`:
  Upstream dependency updated, too close to a glitch-soc-only
  dependency in the file.
pull/1350/head
Thibaut Girka 2020-06-09 10:39:20 +02:00
commit 12c8ac9e14
246 changed files with 5027 additions and 1354 deletions

View File

@ -1,28 +0,0 @@
version: 1
update_configs:
- package_manager: "ruby:bundler"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security
- package_manager: "javascript"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security

22
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,22 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 99
allow:
- dependency-type: all
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 99
allow:
- dependency-type: all

10
Gemfile
View File

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.5'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.66', require: false
gem 'aws-sdk-s3', '~> 1.67', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -50,6 +50,7 @@ gem 'omniauth', '~> 1.9'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@ -83,7 +84,7 @@ gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 1.1'
gem 'ruby-progressbar', '~> 1.10'
gem 'sanitize', '~> 5.1'
gem 'sanitize', '~> 5.2'
gem 'sidekiq', '~> 6.0'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
@ -93,7 +94,6 @@ gem 'simple_form', '~> 5.0'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.0'
gem 'strong_migrations', '~> 0.6'
gem 'tty-command', '~> 0.9', require: false
gem 'tty-prompt', '~> 0.21', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020'
@ -122,7 +122,7 @@ end
group :test do
gem 'capybara', '~> 3.32'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.11'
gem 'faker', '~> 2.12'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
@ -141,7 +141,7 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler'
gem 'rubocop', '~> 0.84', require: false
gem 'rubocop', '~> 0.85', require: false
gem 'rubocop-rails', '~> 2.5', require: false
gem 'brakeman', '~> 4.8', require: false
gem 'bundler-audit', '~> 0.6', require: false

View File

@ -92,20 +92,20 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.1.0)
aws-partitions (1.320.0)
aws-sdk-core (3.96.1)
aws-partitions (1.326.0)
aws-sdk-core (3.98.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.31.0)
aws-sdk-kms (1.33.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.66.0)
aws-sdk-s3 (1.67.1)
aws-sdk-core (~> 3, >= 3.96.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.3)
aws-sigv4 (1.1.4)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.13)
better_errors (2.7.1)
@ -119,7 +119,7 @@ GEM
bootsnap (1.4.6)
msgpack (~> 1.0)
brakeman (4.8.2)
browser (4.1.0)
browser (4.2.0)
builder (3.2.4)
bullet (6.1.0)
activesupport (>= 3.0.0)
@ -164,9 +164,9 @@ GEM
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.2)
coderay (1.1.3)
concurrent-ruby (1.1.6)
connection_pool (2.2.2)
connection_pool (2.2.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
@ -201,6 +201,7 @@ GEM
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
ed25519 (1.2.4)
elasticsearch (7.7.0)
elasticsearch-api (= 7.7.0)
elasticsearch-transport (= 7.7.0)
@ -217,7 +218,7 @@ GEM
tzinfo
excon (0.73.0)
fabrication (2.21.1)
faker (2.11.0)
faker (2.12.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@ -235,14 +236,14 @@ GEM
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-openstack (0.3.7)
fog-openstack (0.3.10)
fog-core (>= 1.45, <= 2.1.0)
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fugit (1.3.5)
fugit (1.3.6)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.1)
raabro (~> 1.3)
fuubar (2.5.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
@ -284,7 +285,7 @@ GEM
httplog (1.4.2)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.8.2)
i18n (1.8.3)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.31)
activesupport (>= 4.0.2)
@ -309,7 +310,7 @@ GEM
multi_json (~> 1.14)
rack (~> 2.0)
rdf (~> 3.1)
json-ld-preloaded (3.1.2)
json-ld-preloaded (3.1.3)
json-ld (~> 3.1)
rdf (~> 3.1)
jsonapi-renderer (0.2.2)
@ -406,7 +407,7 @@ GEM
parallel (1.19.1)
parallel_tests (2.32.0)
parallel
parser (2.7.1.2)
parser (2.7.1.3)
ast (~> 2.4.0)
parslet (2.0.0)
pastel (0.7.4)
@ -484,7 +485,7 @@ GEM
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (13.0.1)
rdf (3.1.1)
rdf (3.1.2)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0)
@ -509,10 +510,10 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.8.2)
redis (>= 4, < 5)
regexp_parser (1.7.0)
regexp_parser (1.7.1)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.0)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.4)
@ -544,10 +545,11 @@ GEM
rspec-support (3.9.3)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.84.0)
rubocop (0.85.1)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
rexml
rubocop-ast (>= 0.0.3)
ruby-progressbar (~> 1.7)
@ -564,7 +566,7 @@ GEM
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
sanitize (5.1.0)
sanitize (5.2.0)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
@ -623,8 +625,6 @@ GEM
thwait (0.1.0)
tilt (2.0.10)
tty-color (0.5.1)
tty-command (0.9.0)
pastel (~> 0.7.0)
tty-cursor (0.7.1)
tty-prompt (0.21.0)
necromancer (~> 0.5.0)
@ -634,7 +634,7 @@ GEM
tty-cursor (~> 0.7)
tty-screen (~> 0.7)
wisper (~> 2.0.0)
tty-screen (0.7.1)
tty-screen (0.8.0)
twitter-text (1.14.7)
unf (~> 0.1.0)
tzinfo (1.2.7)
@ -662,7 +662,7 @@ GEM
jwt (~> 2.0)
websocket-driver (0.7.2)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4)
websocket-extensions (0.1.5)
wisper (2.0.1)
xpath (3.2.0)
nokogiri (~> 1.8)
@ -675,7 +675,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 3.1)
aws-sdk-s3 (~> 1.66)
aws-sdk-s3 (~> 1.67)
better_errors (~> 2.7)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@ -702,8 +702,9 @@ DEPENDENCIES
doorkeeper (~> 5.4)
dotenv-rails (~> 2.7)
e2mmap (~> 0.1.0)
ed25519 (~> 1.2)
fabrication (~> 2.21)
faker (~> 2.11)
faker (~> 2.12)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -773,10 +774,10 @@ DEPENDENCIES
rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.0)
rspec_junit_formatter (~> 0.4)
rubocop (~> 0.84)
rubocop (~> 0.85)
rubocop-rails (~> 2.5)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sanitize (~> 5.2)
sidekiq (~> 6.0)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
@ -792,7 +793,6 @@ DEPENDENCIES
strong_migrations (~> 0.6)
thor (~> 0.20)
thwait (~> 0.1.0)
tty-command (~> 0.9)
tty-prompt (~> 0.21)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2020)

12
SECURITY.md Normal file
View File

@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 3.1.x | :white_check_mark: |
| < 3.1 | :x: |
## Reporting a Vulnerability
hello@joinmastodon.org

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
class AccountsController < ApplicationController
PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200
include AccountControllerConcern
include SignatureAuthentication
@ -41,7 +42,8 @@ class AccountsController < ApplicationController
format.rss do
expires_in 1.minute, public: true
@statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE)
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class ActivityPub::ClaimsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
skip_before_action :authenticate_user!
before_action :require_signature!
before_action :set_claim_result
def create
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
end
private
def set_claim_result
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
end
end

View File

@ -5,8 +5,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
include AccountOwnedConcern
before_action :require_signature!, if: :authorized_fetch_mode?
before_action :set_items
before_action :set_size
before_action :set_statuses
before_action :set_type
before_action :set_cache_headers
def show
@ -16,40 +17,53 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
private
def set_statuses
@statuses = scope_for_collection
@statuses = cache_collection(@statuses, Status)
end
def set_size
def set_items
case params[:id]
when 'featured'
@size = @account.pinned_statuses.not_local_only.count
@items = begin
# Because in public fetch mode we cache the response, there would be no
# benefit from performing the check below, since a blocked account or domain
# would likely be served the cache from the reverse proxy anyway
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
[]
else
cache_collection(@account.pinned_statuses.not_local_only, Status)
end
end
when 'devices'
@items = @account.devices
else
not_found
end
end
def scope_for_collection
def set_size
case params[:id]
when 'featured', 'devices'
@size = @items.size
else
not_found
end
end
def set_type
case params[:id]
when 'featured'
# Because in public fetch mode we cache the response, there would be no
# benefit from performing the check below, since a blocked account or domain
# would likely be served the cache from the reverse proxy anyway
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
Status.none
else
@account.pinned_statuses.not_local_only
end
@type = :ordered
when 'devices'
@type = :unordered
else
not_found
end
end
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_collection_url(@account, params[:id]),
type: :ordered,
type: @type,
size: @size,
items: @statuses
items: @items
)
end
end

View File

@ -33,6 +33,8 @@ module Admin
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_custom_emojis_path(filter_params)
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::Crypto::DeliveriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def create
devices.each do |device_params|
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
end
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def resource_params
params.require(:device)
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
LIMIT = 80
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
before_action :set_encrypted_messages, only: :index
after_action :insert_pagination_headers, only: :index
def index
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
end
def clear
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
render_empty
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
def set_encrypted_messages
@encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end
def pagination_max_id
@encrypted_messages.last.id
end
def pagination_since_id
@encrypted_messages.first.id
end
def records_continue?
@encrypted_messages.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_claim_results
def create
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
end
private
def set_claim_results
@claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact
end
def resource_params
params.permit(device: [:account_id, :device_id])
end
def devices
Array(resource_params[:device])
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::CountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device
def show
render json: { one_time_keys: @current_device.one_time_keys.count }
end
private
def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_accounts
before_action :set_query_results
def create
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
end
private
def set_accounts
@accounts = Account.where(id: account_ids).includes(:devices)
end
def set_query_results
@query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact
end
def account_ids
Array(params[:id]).map(&:to_i)
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
def create
device = Device.find_or_initialize_by(access_token: doorkeeper_token)
device.transaction do
device.account = current_account
device.update!(resource_params[:device])
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
resource_params[:one_time_keys].each do |one_time_key_params|
device.one_time_keys.create!(one_time_key_params)
end
end
end
render json: device, serializer: REST::Keys::DeviceSerializer
end
private
def resource_params
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
end
end

View File

@ -9,7 +9,9 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional!
prepend_before_action :set_pack
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
include TwoFactorAuthenticationConcern
include SignInTokenAuthenticationConcern
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
@ -40,8 +42,8 @@ class Auth::SessionsController < Devise::SessionsController
protected
def find_user
if session[:otp_user_id]
User.find(session[:otp_user_id])
if session[:attempt_user_id]
User.find(session[:attempt_user_id])
else
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@ -50,7 +52,7 @@ class Auth::SessionsController < Devise::SessionsController
end
def user_params
params.require(:user).permit(:email, :password, :otp_attempt)
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
end
def after_sign_in_path_for(resource)
@ -71,48 +73,6 @@ class Auth::SessionsController < Devise::SessionsController
super
end
def two_factor_enabled?
find_user&.otp_required_for_login?
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end
def authenticate_with_two_factor
user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
# If encrypted_password is blank, we got the user from LDAP or PAM,
# so credentials are already valid
prompt_for_two_factor(user)
end
end
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:otp_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end
def prompt_for_two_factor(user)
session[:otp_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :two_factor
end
def require_no_authentication
super
# Delete flash message that isn't entirely useful and may be confusing in

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module SignInTokenAuthenticationConcern
extend ActiveSupport::Concern
included do
prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
end
def sign_in_token_required?
find_user&.suspicious_sign_in?(request.remote_ip)
end
def valid_sign_in_token_attempt?(user)
Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
end
def authenticate_with_sign_in_token
user = self.resource = find_user
if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_sign_in_token(user)
end
end
def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
prompt_for_sign_in_token(user)
end
end
def prompt_for_sign_in_token(user)
if user.sign_in_token_expired?
user.generate_sign_in_token && user.save
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
end
session[:attempt_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :sign_in_token
end
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
module TwoFactorAuthenticationConcern
extend ActiveSupport::Concern
included do
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
end
def two_factor_enabled?
find_user&.otp_required_for_login?
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end
def authenticate_with_two_factor
user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end
def authenticate_with_two_factor_attempt(user)
if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end
def prompt_for_two_factor(user)
session[:attempt_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :two_factor
end
end

View File

@ -18,7 +18,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
if @redirect.valid_with_challenge?(current_user)
current_account.update!(moved_to_account: @redirect.target_account)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
redirect_to settings_migration_path, notice: I18n.t('migrations.redirected_msg', acct: current_account.moved_to_account.acct)
else
render :new
end

View File

@ -44,7 +44,7 @@ class StatusesController < ApplicationController
def activity
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
end
def embed

View File

@ -3,7 +3,8 @@
class TagsController < ApplicationController
include SignatureVerification
PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200
layout 'public'
@ -26,6 +27,7 @@ class TagsController < ApplicationController
format.rss do
expires_in 0, public: true
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)

View File

@ -137,6 +137,11 @@ module ApplicationHelper
text: [params[:title], params[:text], params[:url]].compact.join(' '),
}
permit_visibilities = %w(public unlisted private direct)
default_privacy = current_account&.user&.setting_default_privacy
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]
if user_signed_in?
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)

View File

@ -1,5 +1,16 @@
# frozen_string_literal: true
# Monkey-patch on monkey-patch.
# Because it conflicts with the request.rb patch.
class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
end
end
module WebfingerHelper
def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
@ -12,6 +23,14 @@ module WebfingerHelper
headers: {
'User-Agent': Mastodon::Version.user_agent,
},
timeout_class: HTTP::Timeout::PerOperationOriginal,
timeout_options: {
write_timeout: 10,
connect_timeout: 5,
read_timeout: 10,
},
}
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger

View File

@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}

View File

@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
ref={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}

View File

@ -401,6 +401,7 @@ class Status extends ImmutablePureComponent {
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/>
);
}

View File

@ -76,7 +76,7 @@ describe('emoji', () => {
it('skips the textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />');
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
});
});
});

View File

@ -6,6 +6,15 @@ const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
// Emoji requiring extra borders depending on theme
const darkEmoji = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴';
const lightEmoji = '👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️';
const emojiFilename = (filename, match) => {
const borderedEmoji = document.body.classList.contains('theme-mastodon-light') ? lightEmoji : darkEmoji;
return borderedEmoji.includes(match) ? (filename + '_border') : filename;
};
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@ -60,7 +69,7 @@ const emojify = (str, customEmojis = {}) => {
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename, match)}.svg" />`;
rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) {

View File

@ -2,9 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import punycode from 'punycode';
import classnames from 'classnames';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { useBlurhash } from 'mastodon/initial_state';
import { decode } from 'blurhash';
const IDNA_PREFIX = 'xn--';
@ -63,6 +67,7 @@ export default class Card extends React.PureComponent {
compact: PropTypes.bool,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
};
static defaultProps = {
@ -72,12 +77,44 @@ export default class Card extends React.PureComponent {
state = {
width: this.props.defaultWidth || 280,
previewLoaded: false,
embedded: false,
revealed: !this.props.sensitive,
};
componentWillReceiveProps (nextProps) {
if (!Immutable.is(this.props.card, nextProps.card)) {
this.setState({ embedded: false });
this.setState({ embedded: false, previewLoaded: false });
}
if (this.props.sensitive !== nextProps.sensitive) {
this.setState({ revealed: !nextProps.sensitive });
}
}
componentDidMount () {
if (this.props.card && this.props.card.get('blurhash')) {
this._decode();
}
}
componentDidUpdate (prevProps) {
const { card } = this.props;
if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
this._decode();
}
}
_decode () {
if (!useBlurhash) return;
const hash = this.props.card.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
}
}
@ -119,6 +156,18 @@ export default class Card extends React.PureComponent {
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ previewLoaded: true });
}
handleReveal = () => {
this.setState({ revealed: true });
}
renderVideo () {
const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) };
@ -138,7 +187,7 @@ export default class Card extends React.PureComponent {
render () {
const { card, maxDescription, compact } = this.props;
const { width, embedded } = this.state;
const { width, embedded, revealed } = this.state;
if (card === null) {
return null;
@ -153,7 +202,7 @@ export default class Card extends React.PureComponent {
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
const description = (
<div className='status-card__content'>
<div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
{title}
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
<span className='status-card__host'>{provider}</span>
@ -161,7 +210,18 @@ export default class Card extends React.PureComponent {
);
let embed = '';
let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
</button>
);
spoilerButton = (
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
{spoilerButton}
</div>
);
if (interactive) {
if (embedded) {
@ -175,14 +235,18 @@ export default class Card extends React.PureComponent {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
<div className='status-card__actions'>
<div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
{revealed && (
<div className='status-card__actions'>
<div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
</div>
</div>
</div>
)}
{!revealed && spoilerButton}
</div>
);
}
@ -196,13 +260,16 @@ export default class Card extends React.PureComponent {
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
{!revealed && spoilerButton}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' />
{!revealed && spoilerButton}
</div>
);
}

View File

@ -153,7 +153,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
);
}
} else if (status.get('spoiler_text').length === 0) {
media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
}
if (status.get('application')) {

View File

@ -106,7 +106,7 @@
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this status?",
"confirmations.delete.message": "Are you sure you want to delete this toot?",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Block entire domain",
@ -117,7 +117,7 @@
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
@ -130,7 +130,7 @@
"directory.local": "From {domain} only",
"directory.new_arrivals": "New arrivals",
"directory.recently_active": "Recently active",
"embed.instructions": "Embed this status on your website by copying the code below.",
"embed.instructions": "Embed this toot on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.custom": "Custom",
@ -159,7 +159,7 @@
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
"empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
"empty_column.list": "There is nothing in this list yet. When members of this list post new toots, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
@ -216,12 +216,12 @@
"keyboard_shortcuts.back": "to navigate back",
"keyboard_shortcuts.blocked": "to open blocked users list",
"keyboard_shortcuts.boost": "to boost",
"keyboard_shortcuts.column": "to focus a status in one of the columns",
"keyboard_shortcuts.column": "to focus a toot in one of the columns",
"keyboard_shortcuts.compose": "to focus the compose textarea",
"keyboard_shortcuts.description": "Description",
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "to move down in the list",
"keyboard_shortcuts.enter": "to open status",
"keyboard_shortcuts.enter": "to open toot",
"keyboard_shortcuts.favourite": "to favourite",
"keyboard_shortcuts.favourites": "to open favourites list",
"keyboard_shortcuts.federated": "to open federated timeline",
@ -289,13 +289,13 @@
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security",
"notification.favourite": "{name} favourited your status",
"notification.favourite": "{name} favourited your toot",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your status",
"notification.reblog": "{name} boosted your toot",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
@ -326,7 +326,7 @@
"poll.voted": "You voted for this answer",
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Adjust status privacy",
"privacy.change": "Adjust toot privacy",
"privacy.direct.long": "Visible for mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Visible for followers only",
@ -353,9 +353,9 @@
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.full_text": "Simple text returns toots you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.status": "toot",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.accounts": "People",
@ -364,12 +364,12 @@
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this status in the moderation interface",
"status.admin_status": "Open this toot in the moderation interface",
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status",
"status.copy": "Copy link to toot",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
@ -382,7 +382,7 @@
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
"status.open": "Expand this toot",
"status.pin": "Pin on profile",
"status.pinned": "Pinned toot",
"status.read_more": "Read more",

View File

@ -39,3 +39,5 @@ $account-background-color: $white !default;
@function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
}
$emojis-requiring-inversion: 'chains';

View File

@ -1,14 +1,13 @@
$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';
$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
%white-emoji-outline {
filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);
transform: scale(.71);
%emoji-color-inversion {
filter: invert(1);
}
.emojione {
@each $emoji in $black-emojis {
@each $emoji in $emojis-requiring-inversion {
&[title=':#{$emoji}:'] {
@extend %white-emoji-outline;
@extend %emoji-color-inversion;
}
}
}

View File

@ -3097,6 +3097,11 @@ a.status-card {
flex: 1 1 auto;
overflow: hidden;
padding: 14px 14px 14px 8px;
&--blurred {
filter: blur(2px);
pointer-events: none;
}
}
.status-card__description {
@ -3134,7 +3139,8 @@ a.status-card {
width: 100%;
}
.status-card__image-image {
.status-card__image-image,
.status-card__image-preview {
border-radius: 4px 4px 0 0;
}
@ -3179,6 +3185,24 @@ a.status-card.compact:hover {
background-position: center center;
}
.status-card__image-preview {
border-radius: 4px 0 0 4px;
display: block;
margin: 0;
width: 100%;
height: 100%;
object-fit: fill;
position: absolute;
top: 0;
left: 0;
z-index: 0;
background: $base-overlay-background;
&--hidden {
display: none;
}
}
.load-more {
display: block;
color: $dark-text-color;

View File

@ -2,6 +2,45 @@
class ActivityPub::Activity::Create < ActivityPub::Activity
def perform
case @object['type']
when 'EncryptedMessage'
create_encrypted_message
else
create_status
end
end
private
def create_encrypted_message
return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank?
target_account = Account.find(@options[:delivered_to_account_id])
target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId'))
return if target_device.nil?
target_device.encrypted_messages.create!(
from_account: @account,
from_device_id: @object.dig('attributedTo', 'deviceId'),
type: @object['messageType'],
body: @object['cipherText'],
digest: @object.dig('digest', 'digestValue'),
message_franking: message_franking.to_token
)
end
def message_franking
MessageFranking.new(
hmac: @object.dig('digest', 'digestValue'),
original_franking: @object['messageFranking'],
source_account_id: @account.id,
target_account_id: @options[:delivered_to_account_id],
timestamp: Time.now.utc
)
end
def create_status
return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
RedisLock.acquire(lock_options) do |lock|
@ -23,8 +62,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@status
end
private
def audience_to
@object['to'] || @json['to']
end
@ -262,6 +299,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def poll_vote!
poll = replied_to_status.preloadable_poll
already_voted = true
RedisLock.acquire(poll_lock_options) do |lock|
if lock.acquired?
already_voted = poll.votes.where(account: @account).exists?
@ -270,20 +308,24 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
raise Mastodon::RaceConditionError
end
end
increment_voters_count! unless already_voted
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals?
end
def resolve_thread(status)
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
end
def fetch_replies(status)
collection = @object['replies']
return if collection.nil?
replies = ActivityPub::FetchRepliesService.new.call(status, collection, false)
return unless replies.nil?
uri = value_or_id(collection)
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
end
@ -291,6 +333,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def conversation_from_uri(uri)
return nil if uri.nil?
return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
begin
Conversation.find_or_create_by!(uri: uri)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
@ -404,6 +447,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def skip_download?
return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.reject_media?(@account.domain)
end
@ -436,11 +480,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def forward_for_reply
return unless @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
end
def increment_voters_count!
poll = replied_to_status.preloadable_poll
unless poll.voters_count.nil?
poll.voters_count = poll.voters_count + 1
poll.save

View File

@ -22,6 +22,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
}.freeze
def self.default_key_transform

View File

@ -287,9 +287,14 @@ class FeedManager
combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
status = status.reblog if status.reblog?
!combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) ||
(status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?)
combined_text = [
Formatter.instance.plaintext(status),
status.spoiler_text,
status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil,
status.media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
!combined_regex.match(combined_text).nil?
end
# Adds a status to an account's feed, returning true if a status was

View File

@ -19,6 +19,8 @@ class InlineRenderer
serializer = REST::AnnouncementSerializer
when :reaction
serializer = REST::ReactionSerializer
when :encrypted_message
serializer = REST::EncryptedMessageSerializer
else
return
end

View File

@ -126,4 +126,21 @@ class UserMailer < Devise::Mailer
reply_to: Setting.site_contact_email
end
end
def sign_in_token(user, remote_ip, user_agent, timestamp)
@resource = user
@instance = Rails.configuration.x.local_domain
@remote_ip = remote_ip
@user_agent = user_agent
@detection = Browser.new(user_agent)
@timestamp = timestamp.to_time.utc
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email,
subject: I18n.t('user_mailer.sign_in_token.subject'),
reply_to: Setting.site_contact_email
end
end
end

View File

@ -49,6 +49,7 @@
# hide_collections :boolean
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# devices_url :string
#
class Account < ApplicationRecord

View File

@ -9,6 +9,7 @@ module AccountAssociations
# Identity proofs
has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
has_many :devices, dependent: :destroy, inverse_of: :account
# Timelines
has_many :statuses, inverse_of: :account, dependent: :destroy

35
app/models/device.rb Normal file
View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: devices
#
# id :bigint(8) not null, primary key
# access_token_id :bigint(8)
# account_id :bigint(8)
# device_id :string default(""), not null
# name :string default(""), not null
# fingerprint_key :text default(""), not null
# identity_key :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Device < ApplicationRecord
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken'
belongs_to :account
has_many :one_time_keys, dependent: :destroy, inverse_of: :device
has_many :encrypted_messages, dependent: :destroy, inverse_of: :device
validates :name, :fingerprint_key, :identity_key, presence: true
validates :fingerprint_key, :identity_key, ed25519_key: true
before_save :invalidate_associations, if: -> { device_id_changed? || fingerprint_key_changed? || identity_key_changed? }
private
def invalidate_associations
one_time_keys.destroy_all
encrypted_messages.destroy_all
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_messages
#
# id :bigint(8) not null, primary key
# device_id :bigint(8)
# from_account_id :bigint(8)
# from_device_id :string default(""), not null
# type :integer default(0), not null
# body :text default(""), not null
# digest :text default(""), not null
# message_franking :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class EncryptedMessage < ApplicationRecord
self.inheritance_column = nil
include Paginable
scope :up_to, ->(id) { where(arel_table[:id].lteq(id)) }
belongs_to :device
belongs_to :from_account, class_name: 'Account'
around_create Mastodon::Snowflake::Callbacks
after_commit :push_to_streaming_api
private
def push_to_streaming_api
Rails.logger.info(streaming_channel)
Rails.logger.info(subscribed_to_timeline?)
return if destroyed? || !subscribed_to_timeline?
PushEncryptedMessageWorker.perform_async(id)
end
def subscribed_to_timeline?
Redis.current.exists("subscribed:#{streaming_channel}")
end
def streaming_channel
"timeline:#{device.account_id}:#{device.device_id}"
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class MessageFranking
attr_reader :hmac, :source_account_id, :target_account_id,
:timestamp, :original_franking
def initialize(attributes = {})
@hmac = attributes[:hmac]
@source_account_id = attributes[:source_account_id]
@target_account_id = attributes[:target_account_id]
@timestamp = attributes[:timestamp]
@original_franking = attributes[:original_franking]
end
def to_token
crypt = ActiveSupport::MessageEncryptor.new(SystemKey.current_key, serializer: Oj)
crypt.encrypt_and_sign(self)
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: one_time_keys
#
# id :bigint(8) not null, primary key
# device_id :bigint(8)
# key_id :string default(""), not null
# key :text default(""), not null
# signature :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class OneTimeKey < ApplicationRecord
belongs_to :device
validates :key_id, :key, :signature, presence: true
validates :key, ed25519_key: true
validates :signature, ed25519_signature: { message: :key, verify_key: ->(one_time_key) { one_time_key.device.fingerprint_key } }
end

View File

@ -23,19 +23,25 @@
# updated_at :datetime not null
# embed_url :string default(""), not null
# image_storage_schema_version :integer
# blurhash :string
#
class PreviewCard < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
LIMIT = 1.megabytes
BLURHASH_OPTIONS = {
x_comp: 4,
y_comp: 4,
}.freeze
self.inheritance_column = false
enum type: [:link, :photo, :video, :rich]
has_and_belongs_to_many :statuses
has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
include Attachmentable
@ -72,6 +78,7 @@ class PreviewCard < ApplicationRecord
geometry: '400x400>',
file_geometry_parser: FastGeometryParser,
convert_options: '-coalesce -strip',
blurhash: BLURHASH_OPTIONS,
},
}

41
app/models/system_key.rb Normal file
View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: system_keys
#
# id :bigint(8) not null, primary key
# key :binary
# created_at :datetime not null
# updated_at :datetime not null
#
class SystemKey < ApplicationRecord
ROTATION_PERIOD = 1.week.freeze
before_validation :set_key
scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) }
class << self
def current_key
previous_key = order(id: :asc).last
if previous_key && previous_key.created_at >= ROTATION_PERIOD.ago
previous_key.key
else
create.key
end
end
end
private
def set_key
return if key.present?
cipher = OpenSSL::Cipher.new('AES-256-GCM')
cipher.encrypt
self.key = cipher.random_key
end
end

View File

@ -38,6 +38,8 @@
# chosen_languages :string is an Array
# created_by_application_id :bigint(8)
# approved :boolean default(TRUE), not null
# sign_in_token :string
# sign_in_token_sent_at :datetime
#
class User < ApplicationRecord
@ -114,7 +116,7 @@ class User < ApplicationRecord
:default_content_type, :system_emoji_font,
to: :settings, prefix: :setting, allow_nil: false
attr_reader :invite_code
attr_reader :invite_code, :sign_in_token_attempt
attr_writer :external
def confirmed?
@ -168,6 +170,10 @@ class User < ApplicationRecord
true
end
def suspicious_sign_in?(ip)
!otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
end
def functional?
confirmed? && approved? && !disabled? && !account.suspended?
end
@ -270,6 +276,13 @@ class User < ApplicationRecord
super
end
def external_or_valid_password?(compare_password)
# If encrypted_password is blank, we got the user from LDAP or PAM,
# so credentials are already valid
encrypted_password.blank? || valid_password?(compare_password)
end
def send_reset_password_instructions
return false if encrypted_password.blank?
@ -305,6 +318,15 @@ class User < ApplicationRecord
end
end
def sign_in_token_expired?
sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago
end
def generate_sign_in_token
self.sign_in_token = Devise.friendly_token(6)
self.sign_in_token_sent_at = Time.now.utc
end
protected
def send_devise_notification(notification, *args)
@ -321,6 +343,10 @@ class User < ApplicationRecord
private
def recent_ip?(ip)
recent_ips.any? { |(_, recent_ip)| recent_ip == ip }
end
def send_pending_devise_notifications
pending_devise_notifications.each do |notification, args|
render_and_send_devise_message(notification, *args)

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
class << self
def from_status(status)
new.tap do |presenter|
presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status)
presenter.type = status.reblog? ? 'Announce' : 'Create'
presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account)
presenter.published = status.created_at
presenter.to = ActivityPub::TagManager.instance.to(status)
presenter.cc = ActivityPub::TagManager.instance.cc(status)
presenter.virtual_object = begin
if status.reblog?
if status.account == status.proper.account && status.proper.private_visibility? && status.local?
status.proper
else
ActivityPub::TagManager.instance.uri_for(status.proper)
end
else
status.proper
end
end
end
end
def from_encrypted_message(encrypted_message)
new.tap do |presenter|
presenter.id = ActivityPub::TagManager.instance.generate_uri_for(nil)
presenter.type = 'Create'
presenter.actor = ActivityPub::TagManager.instance.uri_for(encrypted_message.source_account)
presenter.published = Time.now.utc
presenter.to = ActivityPub::TagManager.instance.uri_for(encrypted_message.target_account)
presenter.virtual_object = encrypted_message
end
end
end
end

View File

@ -2,5 +2,5 @@
class InitialStatePresenter < ActiveModelSerializers::Model
attributes :settings, :push_subscription, :token,
:current_account, :admin, :text
:current_account, :admin, :text, :visibility
end

View File

@ -1,52 +1,22 @@
# frozen_string_literal: true
class ActivityPub::ActivitySerializer < ActivityPub::Serializer
def self.serializer_for(model, options)
case model.class.name
when 'Status'
ActivityPub::NoteSerializer
when 'DeliverToDeviceService::EncryptedMessage'
ActivityPub::EncryptedMessageSerializer
else
super
end
end
attributes :id, :type, :actor, :published, :to, :cc
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object?
attribute :proper_uri, key: :object, unless: :serialize_object?
attribute :atom_uri, if: :announce?
def id
ActivityPub::TagManager.instance.activity_uri_for(object)
end
def type
announce? ? 'Announce' : 'Create'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
has_one :virtual_object, key: :object
def published
object.created_at.iso8601
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
def proper_uri
ActivityPub::TagManager.instance.uri_for(object.proper)
end
def atom_uri
OStatus::TagManager.instance.uri_for(object)
end
def announce?
object.reblog?
end
def serialize_object?
return true unless announce?
# Serialize private self-boosts of local toots
object.account == object.proper.account && object.proper.private_visibility? && object.local?
object.published.iso8601
end
end

View File

@ -7,7 +7,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :identity_proof,
:discoverable
:discoverable, :olm
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured,
@ -20,6 +20,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
has_many :virtual_tags, key: :tag
has_many :virtual_attachments, key: :attachment
attribute :devices, unless: :instance_actor?
attribute :moved_to, if: :moved?
attribute :also_known_as, if: :also_known_as?
@ -38,7 +39,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
has_one :icon, serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists?
delegate :moved?, to: :object
delegate :moved?, :instance_actor?, to: :object
def id
object.instance_actor? ? instance_actor_url : account_url(object)
@ -68,6 +69,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object)
end
def devices
account_collection_url(object, :devices)
end
def outbox
account_outbox_url(object)
end

View File

@ -1,10 +1,28 @@
# frozen_string_literal: true
class ActivityPub::CollectionSerializer < ActivityPub::Serializer
class StringSerializer < ActiveModel::Serializer
# Despite the name, it does not return a hash, but the same can be said of
# the ActiveModel::Serializer::CollectionSerializer class which handles
# arrays.
def serializable_hash(*_args)
object
end
end
def self.serializer_for(model, options)
return ActivityPub::NoteSerializer if model.class.name == 'Status'
return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter'
super
case model.class.name
when 'Status'
ActivityPub::NoteSerializer
when 'Device'
ActivityPub::DeviceSerializer
when 'ActivityPub::CollectionPresenter'
ActivityPub::CollectionSerializer
when 'String'
StringSerializer
else
super
end
end
attribute :id, if: -> { object.id.present? }

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
class ActivityPub::DeviceSerializer < ActivityPub::Serializer
context_extensions :olm
include RoutingHelper
class FingerprintKeySerializer < ActivityPub::Serializer
attributes :type, :public_key_base64
def type
'Ed25519Key'
end
def public_key_base64
object.fingerprint_key
end
end
class IdentityKeySerializer < ActivityPub::Serializer
attributes :type, :public_key_base64
def type
'Curve25519Key'
end
def public_key_base64
object.identity_key
end
end
attributes :device_id, :type, :name, :claim
has_one :fingerprint_key, serializer: FingerprintKeySerializer
has_one :identity_key, serializer: IdentityKeySerializer
def type
'Device'
end
def claim
account_claim_url(object.account, id: object.device_id)
end
def fingerprint_key
object
end
def identity_key
object
end
end

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
class ActivityPub::EncryptedMessageSerializer < ActivityPub::Serializer
context :security
context_extensions :olm
class DeviceSerializer < ActivityPub::Serializer
attributes :type, :device_id
def type
'Device'
end
def device_id
object
end
end
class DigestSerializer < ActivityPub::Serializer
attributes :type, :digest_algorithm, :digest_value
def type
'Digest'
end
def digest_algorithm
'http://www.w3.org/2000/09/xmldsig#hmac-sha256'
end
def digest_value
object
end
end
attributes :type, :message_type, :cipher_text, :message_franking
has_one :attributed_to, serializer: DeviceSerializer
has_one :to, serializer: DeviceSerializer
has_one :digest, serializer: DigestSerializer
def type
'EncryptedMessage'
end
def attributed_to
object.source_device.device_id
end
def to
object.target_device_id
end
def message_type
object.type
end
def cipher_text
object.body
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class ActivityPub::OneTimeKeySerializer < ActivityPub::Serializer
context :security
context_extensions :olm
class SignatureSerializer < ActivityPub::Serializer
attributes :type, :signature_value
def type
'Ed25519Signature'
end
def signature_value
object.signature
end
end
attributes :key_id, :type, :public_key_base64
has_one :signature, serializer: SignatureSerializer
def type
'Curve25519Key'
end
def public_key_base64
object.key
end
def signature
object
end
end

View File

@ -2,7 +2,14 @@
class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer
def self.serializer_for(model, options)
return ActivityPub::ActivitySerializer if model.is_a?(Status)
super
if model.class.name == 'ActivityPub::ActivityPresenter'
ActivityPub::ActivitySerializer
else
super
end
end
def items
object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) }
end
end

View File

@ -3,7 +3,7 @@
class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :to
has_one :object, serializer: ActivityPub::ActivitySerializer
has_one :virtual_object, key: :object, serializer: ActivityPub::ActivitySerializer
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
@ -20,4 +20,8 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
def to
[ActivityPub::TagManager::COLLECTIONS[:public]]
end
def virtual_object
ActivityPub::ActivityPresenter.from_status(object)
end
end

View File

@ -72,7 +72,7 @@ class InitialStateSerializer < ActiveModel::Serializer
if object.current_account
store[:me] = object.current_account.id.to_s
store[:default_privacy] = object.current_account.user.setting_default_privacy
store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy
store[:default_sensitive] = object.current_account.user.setting_default_sensitive
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class REST::EncryptedMessageSerializer < ActiveModel::Serializer
attributes :id, :account_id, :device_id,
:type, :body, :digest, :message_franking,
:created_at
def id
object.id.to_s
end
def account_id
object.from_account_id.to_s
end
def device_id
object.from_device_id
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::Keys::ClaimResultSerializer < ActiveModel::Serializer
attributes :account_id, :device_id, :key_id, :key, :signature
def account_id
object.account.id.to_s
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class REST::Keys::DeviceSerializer < ActiveModel::Serializer
attributes :device_id, :name, :identity_key,
:fingerprint_key
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::Keys::QueryResultSerializer < ActiveModel::Serializer
attributes :account_id
has_many :devices, serializer: REST::Keys::DeviceSerializer
def account_id
object.account.id.to_s
end
end

View File

@ -6,7 +6,7 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
attributes :url, :title, :description, :type,
:author_name, :author_url, :provider_name,
:provider_url, :html, :width, :height,
:image, :embed_url
:image, :embed_url, :blurhash
def image
object.image? ? full_asset_url(object.image.url(:original)) : nil

View File

@ -76,6 +76,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
@account.followers_url = @json['followers'] || ''
@account.featured_collection_url = @json['featured'] || ''
@account.devices_url = @json['devices'] || ''
@account.url = url || @uri
@account.uri = @uri
@account.display_name = @json['name'] || ''

View File

@ -22,7 +22,7 @@ class BackupService < BaseService
account.statuses.with_includes.reorder(nil).find_in_batches do |statuses|
statuses.each do |status|
item = serialize_payload(status, ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
item.delete(:'@context')
unless item[:type] == 'Announce' || item[:object][:attachment].blank?

View File

@ -26,59 +26,20 @@ class BlockDomainService < BaseService
suspend_accounts!
end
clear_media! if domain_block.reject_media?
end
def invalidate_association_caches!
# Normally, associated models of a status are immutable (except for accounts)
# so they are aggressively cached. After updating the media attachments to no
# longer point to a local file, we need to clear the cache to make those
# changes appear in the API and UI
@affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media?
end
def silence_accounts!
blocked_domain_accounts.without_silenced.in_batches.update_all(silenced_at: @domain_block.created_at)
end
def clear_media!
@affected_status_ids = []
clear_account_images!
clear_account_attachments!
clear_emojos!
invalidate_association_caches!
end
def suspend_accounts!
blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account|
blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
end
end
def clear_account_images!
blocked_domain_accounts.reorder(nil).find_each do |account|
account.avatar.destroy if account.avatar.exists?
account.header.destroy if account.header.exists?
account.save
end
end
def clear_account_attachments!
media_from_blocked_domain.reorder(nil).find_each do |attachment|
@affected_status_ids << attachment.status_id if attachment.status_id.present?
attachment.file.destroy if attachment.file.exists?
attachment.type = :unknown
attachment.save
end
end
def clear_emojos!
emojis_from_blocked_domains.destroy_all
end
def blocked_domain
domain_block.domain
end
@ -86,12 +47,4 @@ class BlockDomainService < BaseService
def blocked_domain_accounts
Account.by_domain_and_subdomains(blocked_domain)
end
def media_from_blocked_domain
MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
end
def emojis_from_blocked_domains
CustomEmoji.by_domain_and_subdomains(blocked_domain)
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
class ClearDomainMediaService < BaseService
attr_reader :domain_block
def call(domain_block)
@domain_block = domain_block
clear_media! if domain_block.reject_media?
end
private
def invalidate_association_caches!
# Normally, associated models of a status are immutable (except for accounts)
# so they are aggressively cached. After updating the media attachments to no
# longer point to a local file, we need to clear the cache to make those
# changes appear in the API and UI
@affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
end
def clear_media!
@affected_status_ids = []
begin
clear_account_images!
clear_account_attachments!
clear_emojos!
ensure
invalidate_association_caches!
end
end
def clear_account_images!
blocked_domain_accounts.reorder(nil).find_each do |account|
account.avatar.destroy if account.avatar&.exists?
account.header.destroy if account.header&.exists?
account.save
end
end
def clear_account_attachments!
media_from_blocked_domain.reorder(nil).find_each do |attachment|
@affected_status_ids << attachment.status_id if attachment.status_id.present?
attachment.file.destroy if attachment.file&.exists?
attachment.type = :unknown
attachment.save
end
end
def clear_emojos!
emojis_from_blocked_domains.destroy_all
end
def blocked_domain
domain_block.domain
end
def blocked_domain_accounts
Account.by_domain_and_subdomains(blocked_domain)
end
def media_from_blocked_domain
MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
end
def emojis_from_blocked_domains
CustomEmoji.by_domain_and_subdomains(blocked_domain)
end
end

View File

@ -5,8 +5,9 @@ module Payloadable
signer = options.delete(:signer)
sign_with = options.delete(:sign_with)
payload = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
object = record.respond_to?(:virtual_object) ? record.virtual_object : record
if (record.respond_to?(:sign?) && record.sign?) && signer && signing_enabled?
if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled?
ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
else
payload

View File

@ -0,0 +1,78 @@
# frozen_string_literal: true
class DeliverToDeviceService < BaseService
include Payloadable
class EncryptedMessage < ActiveModelSerializers::Model
attributes :source_account, :target_account, :source_device,
:target_device_id, :type, :body, :digest,
:message_franking
end
def call(source_account, source_device, options = {})
@source_account = source_account
@source_device = source_device
@target_account = Account.find(options[:account_id])
@target_device_id = options[:device_id]
@body = options[:body]
@type = options[:type]
@hmac = options[:hmac]
set_message_franking!
if @target_account.local?
deliver_to_local!
else
deliver_to_remote!
end
end
private
def set_message_franking!
@message_franking = message_franking.to_token
end
def deliver_to_local!
target_device = @target_account.devices.find_by!(device_id: @target_device_id)
target_device.encrypted_messages.create!(
from_account: @source_account,
from_device_id: @source_device.device_id,
type: @type,
body: @body,
digest: @hmac,
message_franking: @message_franking
)
end
def deliver_to_remote!
ActivityPub::DeliveryWorker.perform_async(
Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_encrypted_message(encrypted_message), ActivityPub::ActivitySerializer)),
@source_account.id,
@target_account.inbox_url
)
end
def message_franking
MessageFranking.new(
source_account_id: @source_account.id,
target_account_id: @target_account.id,
hmac: @hmac,
timestamp: Time.now.utc
)
end
def encrypted_message
EncryptedMessage.new(
source_account: @source_account,
target_account: @target_account,
source_device: @source_device,
target_device_id: @target_device_id,
type: @type,
body: @body,
digest: @hmac,
message_franking: @message_franking
)
end
end

View File

@ -81,7 +81,9 @@ class ImportService < BaseService
end
end
Import::RelationshipWorker.push_bulk(items) do |acct, extra|
head_items = items.uniq { |acct, _| acct.split('@')[1] }
tail_items = items - head_items
Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra|
[@account.id, acct, action, extra]
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class Keys::ClaimService < BaseService
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
class Result < ActiveModelSerializers::Model
attributes :account, :device_id, :key_id,
:key, :signature
def initialize(account, device_id, key_attributes = {})
@account = account
@device_id = device_id
@key_id = key_attributes[:key_id]
@key = key_attributes[:key]
@signature = key_attributes[:signature]
end
end
def call(source_account, target_account_id, device_id)
@source_account = source_account
@target_account = Account.find(target_account_id)
@device_id = device_id
if @target_account.local?
claim_local_key!
else
claim_remote_key!
end
rescue ActiveRecord::RecordNotFound
nil
end
private
def claim_local_key!
device = @target_account.devices.find_by(device_id: @device_id)
key = nil
ApplicationRecord.transaction do
key = device.one_time_keys.order(Arel.sql('random()')).first!
key.destroy!
end
@result = Result.new(@target_account, @device_id, key)
end
def claim_remote_key!
query_result = QueryService.new.call(@target_account)
device = query_result.find(@device_id)
return unless device.present? && device.valid_claim_url?
json = fetch_resource_with_post(device.claim_url)
return unless json.present? && json['publicKeyBase64'].present?
@result = Result.new(@target_account, @device_id, key_id: json['id'], key: json['publicKeyBase64'], signature: json.dig('signature', 'signatureValue'))
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
Rails.logger.debug "Claiming one-time key for #{@target_account.acct}:#{@device_id} failed: #{e}"
nil
end
def fetch_resource_with_post(uri)
build_post_request(uri).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response)
body_to_json(response.body_with_limit) if response.code == 200
end
end
def build_post_request(uri)
Request.new(:post, uri).tap do |request|
request.on_behalf_of(@source_account, :uri)
request.add_headers(HEADERS)
end
end
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
class Keys::QueryService < BaseService
include JsonLdHelper
class Result < ActiveModelSerializers::Model
attributes :account, :devices
def initialize(account, devices)
@account = account
@devices = devices || []
end
def find(device_id)
@devices.find { |device| device.device_id == device_id }
end
end
class Device < ActiveModelSerializers::Model
attributes :device_id, :name, :identity_key, :fingerprint_key
def initialize(attributes = {})
@device_id = attributes[:device_id]
@name = attributes[:name]
@identity_key = attributes[:identity_key]
@fingerprint_key = attributes[:fingerprint_key]
@claim_url = attributes[:claim_url]
end
def valid_claim_url?
return false if @claim_url.blank?
begin
parsed_url = Addressable::URI.parse(@claim_url).normalize
rescue Addressable::URI::InvalidURIError
return false
end
%w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
end
end
def call(account)
@account = account
if @account.local?
query_local_devices!
else
query_remote_devices!
end
Result.new(@account, @devices)
end
private
def query_local_devices!
@devices = @account.devices.map { |device| Device.new(device) }
end
def query_remote_devices!
return if @account.devices_url.blank?
json = fetch_resource(@account.devices_url)
return if json['items'].blank?
@devices = 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'])
end
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
Rails.logger.debug "Querying devices for #{@account.acct} failed: #{e}"
nil
end
end

View File

@ -65,7 +65,7 @@ class ProcessMentionsService < BaseService
def activitypub_json
return @activitypub_json if defined?(@activitypub_json)
@activitypub_json = Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account))
@activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
end
def resolve_account_service

View File

@ -60,6 +60,6 @@ class ReblogService < BaseService
end
def build_json(reblog)
Oj.dump(serialize_payload(reblog, ActivityPub::ActivitySerializer, signer: reblog.account))
Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
end
end

View File

@ -112,6 +112,8 @@ class ResolveAccountService < BaseService
end
def webfinger_update_due?
return false if @options[:check_delivery_availability] && !DeliveryFailureTracker.available?(@domain)
@account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Ed25519KeyValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
key = Base64.decode64(value)
record.errors[attribute] << I18n.t('crypto.errors.invalid_key') unless verified?(key)
end
private
def verified?(key)
Ed25519.validate_key_bytes(key)
rescue ArgumentError
false
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Ed25519SignatureValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
verify_key = Ed25519::VerifyKey.new(Base64.decode64(option_to_value(record, :verify_key)))
signature = Base64.decode64(value)
message = option_to_value(record, :message)
record.errors[attribute] << I18n.t('crypto.errors.invalid_signature') unless verified?(verify_key, signature, message)
end
private
def verified?(verify_key, signature, message)
verify_key.verify(signature, message)
rescue Ed25519::VerifyError, ArgumentError
false
end
def option_to_value(record, key)
if options[key].is_a?(Proc)
options[key].call(record)
else
record.public_send(options[key])
end
end
end

View File

@ -55,12 +55,15 @@
%p= t('about.unavailable_content_html')
- if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.rejecting_media_title')
%p= t('about.unavailable_content_description.rejecting_media')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }
- if (blocks = @blocks.select(&:silence?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.silenced_title')
%p= t('about.unavailable_content_description.silenced')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }
- if (blocks = @blocks.select(&:suspend?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.suspended_title')
%p= t('about.unavailable_content_description.suspended')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }

View File

@ -1,8 +1,9 @@
- content_for :page_title do
= t('admin.custom_emojis.title')
- content_for :heading_actions do
= link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
- if can?(:create, :custom_emoji)
- content_for :heading_actions do
= link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
.filters
.filter-subset
@ -55,9 +56,10 @@
= f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
- if can?(:destroy, :custom_emoji)
= f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
- unless params[:local] == '1'
- if can?(:copy, :custom_emoji) && params[:local] != '1'
= f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
- if params[:local] == '1'

View File

@ -1,6 +1,12 @@
- content_for :page_title do
= t('admin.instances.title')
- content_for :heading_actions do
- if whitelist_mode?
= link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
- else
= link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
.filters
.filter-subset
%strong= t('admin.instances.moderation.title')
@ -10,12 +16,6 @@
- unless whitelist_mode?
%li= filter_link_to t('admin.instances.moderation.limited'), limited: '1'
%div.special-action-button
- if whitelist_mode?
= link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
- else
= link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
- unless whitelist_mode?
= form_tag admin_instances_url, method: 'GET', class: 'simple_form' do
.fields-group

View File

@ -0,0 +1,14 @@
- content_for :page_title do
= t('auth.login')
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
%p.hint.otp-hint= t('users.suspicious_sign_in_confirmation')
.fields-group
= f.input :sign_in_token_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.sign_in_token_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, autofocus: true
.actions
= f.button :button, t('auth.login'), type: :submit
- if Setting.site_contact_email.present?
%p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil))

View File

@ -39,7 +39,7 @@
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
= react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@ -43,7 +43,7 @@
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
= react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do

View File

@ -0,0 +1,105 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_email.png'), alt: ''
%h1= t 'user_mailer.sign_in_token.title'
%p.lead= t 'user_mailer.sign_in_token.explanation'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.input-cell
%table.input{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td= @resource.sign_in_token
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center
%p= t 'user_mailer.sign_in_token.details'
%tr
%td.column-cell.text-center
%p
%strong= "#{t('sessions.ip')}:"
= @remote_ip
%br/
%strong= "#{t('sessions.browser')}:"
%span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")
%br/
= l(@timestamp)
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center
%p= t 'user_mailer.sign_in_token.further_actions'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to edit_user_registration_url do
%span= t 'settings.account_settings'

View File

@ -0,0 +1,17 @@
<%= t 'user_mailer.sign_in_token.title' %>
===
<%= t 'user_mailer.sign_in_token.explanation' %>
=> <%= @resource.sign_in_token %>
<%= t 'user_mailer.sign_in_token.details' %>
<%= t('sessions.ip') %>: <%= @remote_ip %>
<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %>
<%= l(@timestamp) %>
<%= t 'user_mailer.sign_in_token.further_actions' %>
=> <%= edit_user_registration_url %>

View File

@ -43,7 +43,7 @@ class ActivityPub::DistributionWorker
end
def payload
@payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @account))
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
end
def relay!

View File

@ -29,6 +29,6 @@ class ActivityPub::ReplyDistributionWorker
end
def payload
@payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account))
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
end
end

View File

@ -4,8 +4,9 @@ class DomainBlockWorker
include Sidekiq::Worker
def perform(domain_block_id, update = false)
BlockDomainService.new.call(DomainBlock.find(domain_block_id), update)
rescue ActiveRecord::RecordNotFound
true
domain_block = DomainBlock.find_by(id: domain_block_id)
return true if domain_block.nil?
BlockDomainService.new.call(domain_block, update)
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class DomainClearMediaWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(domain_block_id)
domain_block = DomainBlock.find_by(id: domain_block_id)
return true if domain_block.nil?
ClearDomainMediaService.new.call(domain_block)
end
end

View File

@ -7,7 +7,8 @@ class Import::RelationshipWorker
def perform(account_id, target_account_uri, relationship, options = {})
from_account = Account.find(account_id)
target_account = ResolveAccountService.new.call(target_account_uri)
target_domain = domain(target_account_uri)
target_account = stoplight_wrap_request(target_domain) { ResolveAccountService.new.call(target_account_uri, { check_delivery_availability: true }) }
options.symbolize_keys!
return if target_account.nil?
@ -29,4 +30,22 @@ class Import::RelationshipWorker
rescue ActiveRecord::RecordNotFound
true
end
def domain(uri)
domain = uri.is_a?(Account) ? uri.domain : uri.split('@')[1]
TagManager.instance.local_domain?(domain) ? nil : TagManager.instance.normalize_domain(domain)
end
def stoplight_wrap_request(domain, &block)
if domain.present?
Stoplight("source:#{domain}", &block)
.with_fallback { nil }
.with_threshold(1)
.with_cool_off_time(5.minutes.seconds)
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
.run
else
block.call
end
end
end

View File

@ -2,13 +2,14 @@
class PushConversationWorker
include Sidekiq::Worker
include Redisable
def perform(conversation_account_id)
conversation = AccountConversation.find(conversation_account_id)
message = InlineRenderer.render(conversation, conversation.account, :conversation)
timeline_id = "timeline:direct:#{conversation.account_id}"
Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
redis.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
rescue ActiveRecord::RecordNotFound
true
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class PushEncryptedMessageWorker
include Sidekiq::Worker
include Redisable
def perform(encrypted_message_id)
encrypted_message = EncryptedMessage.find(encrypted_message_id)
message = InlineRenderer.render(encrypted_message, nil, :encrypted_message)
timeline_id = "timeline:#{encrypted_message.device.account_id}:#{encrypted_message.device.device_id}"
redis.publish(timeline_id, Oj.dump(event: :encrypted_message, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -8,5 +8,6 @@ class Scheduler::DoorkeeperCleanupScheduler
def perform
Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
SystemKey.expired.delete_all
end
end

View File

@ -1,25 +1,5 @@
{
"ignored_warnings": [
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "0117d2be5947ea4e4fbed9c15f23c6615b12c6892973411820c83d079808819d",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/search_controller.rb",
"line": 30,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:type, :offset, :min_id, :max_id, :account_id)",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V1::SearchController",
"method": "search_params"
},
"user_input": ":account_id",
"confidence": "High",
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
@ -27,7 +7,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/report.rb",
"line": 90,
"line": 112,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")",
"render_path": null,
@ -47,7 +27,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/status.rb",
"line": 87,
"line": 100,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
"render_path": null,
@ -61,39 +41,62 @@
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "28d81cc22580ef76e912b077b245f353499aa27b3826476667224c00227af2a9",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/admin/reports_controller.rb",
"line": 56,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:account_id, :resolved, :target_account_id)",
"render_path": null,
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "20a660939f2bbf8c665e69f2844031c0564524689a9570a0091ed94846212020",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/admin/action_logs/index.html.haml",
"line": 26,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => Admin::ActionLogFilter.new(filter_params).results.page(params[:page]), {})",
"render_path": [
{
"type": "controller",
"class": "Admin::ActionLogsController",
"method": "index",
"line": 8,
"file": "app/controllers/admin/action_logs_controller.rb",
"rendered": {
"name": "admin/action_logs/index",
"file": "app/views/admin/action_logs/index.html.haml"
}
}
],
"location": {
"type": "method",
"class": "Admin::ReportsController",
"method": "filter_params"
"type": "template",
"template": "admin/action_logs/index"
},
"user_input": ":account_id",
"confidence": "High",
"user_input": "params[:page]",
"confidence": "Weak",
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "4b6a895e2805578d03ceedbe1d469cc75a0c759eba093722523edb4b8683c873",
"fingerprint": "371fe16dc4c9d6ab08a20437d65be4825776107a67c38f6d4780a9c703cd44a5",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/admin/action_logs/index.html.haml",
"line": 4,
"file": "app/views/admin/email_domain_blocks/index.html.haml",
"line": 17,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => Admin::ActionLog.page(params[:page]), {})",
"render_path": [{"type":"controller","class":"Admin::ActionLogsController","method":"index","line":7,"file":"app/controllers/admin/action_logs_controller.rb","rendered":{"name":"admin/action_logs/index","file":"/home/eugr/Projects/mastodon/app/views/admin/action_logs/index.html.haml"}}],
"code": "render(action => EmailDomainBlock.where(:parent_id => nil).includes(:children).order(:id => :desc).page(params[:page]), {})",
"render_path": [
{
"type": "controller",
"class": "Admin::EmailDomainBlocksController",
"method": "index",
"line": 10,
"file": "app/controllers/admin/email_domain_blocks_controller.rb",
"rendered": {
"name": "admin/email_domain_blocks/index",
"file": "app/views/admin/email_domain_blocks/index.html.haml"
}
}
],
"location": {
"type": "template",
"template": "admin/action_logs/index"
"template": "admin/email_domain_blocks/index"
},
"user_input": "params[:page]",
"confidence": "Weak",
@ -106,7 +109,7 @@
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/remote_interaction_controller.rb",
"line": 21,
"line": 24,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id])))",
"render_path": null,
@ -119,25 +122,6 @@
"confidence": "High",
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "67afc0d5f7775fa5bd91d1912e1b5505aeedef61876347546fa20f92fd6915e6",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/stream_entries/embed.html.haml",
"line": 3,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true, :autoplay => ActiveModel::Type::Boolean.new.cast(params[:autoplay]) })",
"render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":63,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/embed","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/embed.html.haml"}}],
"location": {
"type": "template",
"template": "stream_entries/embed"
},
"user_input": "params[:id]",
"confidence": "Weak",
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
@ -145,7 +129,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/status.rb",
"line": 92,
"line": 105,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "result.joins(\"LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
"render_path": null,
@ -159,22 +143,43 @@
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "8d843713d99e8403f7992f3e72251b633817cf9076ffcbbad5613859d2bbc127",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/admin/custom_emojis/index.html.haml",
"line": 45,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]), {})",
"render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":11,"file":"app/controllers/admin/custom_emojis_controller.rb","rendered":{"name":"admin/custom_emojis/index","file":"/home/eugr/Projects/mastodon/app/views/admin/custom_emojis/index.html.haml"}}],
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "7631e93d0099506e7c3e5c91ba8d88523b00a41a0834ae30031a5a4e8bb3020a",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v2/search_controller.rb",
"line": 28,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:type, :offset, :min_id, :max_id, :account_id)",
"render_path": null,
"location": {
"type": "template",
"template": "admin/custom_emojis/index"
"type": "method",
"class": "Api::V2::SearchController",
"method": "search_params"
},
"user_input": "params[:page]",
"confidence": "Weak",
"user_input": ":account_id",
"confidence": "High",
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "8f63dec68951d9bcf7eddb15af9392b2e1333003089c41fb76688dfd3579f394",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/crypto/deliveries_controller.rb",
"line": 23,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:device).permit(:account_id, :device_id, :type, :body, :hmac)",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V1::Crypto::DeliveriesController",
"method": "resource_params"
},
"user_input": ":account_id",
"confidence": "High",
"note": ""
},
{
@ -204,10 +209,22 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/admin/accounts/index.html.haml",
"line": 47,
"line": 54,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => filtered_accounts.page(params[:page]), {})",
"render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":12,"file":"app/controllers/admin/accounts_controller.rb","rendered":{"name":"admin/accounts/index","file":"/home/eugr/Projects/mastodon/app/views/admin/accounts/index.html.haml"}}],
"render_path": [
{
"type": "controller",
"class": "Admin::AccountsController",
"method": "index",
"line": 12,
"file": "app/controllers/admin/accounts_controller.rb",
"rendered": {
"name": "admin/accounts/index",
"file": "app/views/admin/accounts/index.html.haml"
}
}
],
"location": {
"type": "template",
"template": "admin/accounts/index"
@ -216,6 +233,26 @@
"confidence": "Weak",
"note": ""
},
{
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "ba568ac09683f98740f663f3d850c31785900215992e8c090497d359a2563d50",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/remote_follow_controller.rb",
"line": 21,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(RemoteFollow.new(resource_params).subscribe_address_for(@account))",
"render_path": null,
"location": {
"type": "method",
"class": "RemoteFollowController",
"method": "create"
},
"user_input": "RemoteFollow.new(resource_params).subscribe_address_for(@account)",
"confidence": "High",
"note": ""
},
{
"warning_type": "Redirect",
"warning_code": 18,
@ -223,7 +260,7 @@
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/media_controller.rb",
"line": 14,
"line": 20,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(MediaAttachment.attached.find_by!(:shortcode => ((params[:id] or params[:medium_id]))).file.url(:original))",
"render_path": null,
@ -236,26 +273,6 @@
"confidence": "High",
"note": ""
},
{
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "bb7e94e60af41decb811bb32171f1b27e9bf3f4d01e9e511127362e22510eb11",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/remote_follow_controller.rb",
"line": 19,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(RemoteFollow.new(resource_params).subscribe_address_for(Account.find_local!(params[:account_username])))",
"render_path": null,
"location": {
"type": "method",
"class": "RemoteFollowController",
"method": "create"
},
"user_input": "RemoteFollow.new(resource_params).subscribe_address_for(Account.find_local!(params[:account_username]))",
"confidence": "High",
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
@ -275,27 +292,8 @@
"user_input": ":account_id",
"confidence": "High",
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "fbd0fc59adb5c6d44b60e02debb31d3af11719f534c9881e21435bbff87404d6",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/stream_entries/show.html.haml",
"line": 23,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })",
"render_path": [{"type":"controller","class":"StatusesController","method":"show","line":34,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/show","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/show.html.haml"}}],
"location": {
"type": "template",
"template": "stream_entries/show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"note": ""
}
],
"updated": "2019-02-21 02:30:29 +0100",
"brakeman_version": "4.4.0"
"updated": "2020-06-01 18:18:02 +0200",
"brakeman_version": "4.8.0"
}

View File

@ -95,7 +95,8 @@ Doorkeeper.configure do
:'admin:read:reports',
:'admin:write',
:'admin:write:accounts',
:'admin:write:reports'
:'admin:write:reports',
:crypto
# Change the way client credentials are retrieved from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then

View File

@ -19,6 +19,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'ActivityStreams'
inflect.acronym 'JsonLd'
inflect.acronym 'NodeInfo'
inflect.acronym 'Ed25519'
inflect.singular 'data', 'data'
end

View File

@ -35,13 +35,16 @@ en:
status_count_before: Who authored
tagline: Follow friends and discover new ones
terms: Terms of service
unavailable_content: Unavailable content
unavailable_content: Moderated servers
unavailable_content_description:
domain: Server
reason: Reason
rejecting_media: 'Media files from these servers will not be processed or stored, and no thumbnails will be displayed, requiring manual click-through to the original file:'
silenced: 'Posts from these servers will be hidden in public timelines and conversations, and no notifications will be generated from their users'' interactions, unless you are following them:'
rejecting_media_title: Filtered media
silenced: 'Posts from these servers will be hidden in public timelines and conversations, and no notifications will be generated from their users interactions, unless you are following them:'
silenced_title: Silenced servers
suspended: 'No data from these servers will be processed, stored or exchanged, making any interaction or communication with users from these servers impossible:'
suspended_title: Suspended servers
unavailable_content_html: Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.
user_count_after:
one: user
@ -309,6 +312,7 @@ en:
listed: Listed
new:
title: Add new custom emoji
not_permitted: You are not permitted to perform this action
overwrite: Overwrite
shortcode: Shortcode
shortcode_hint: At least 2 characters, only alphanumeric characters and underscores
@ -721,6 +725,10 @@ en:
hint_html: "<strong>Tip:</strong> We won't ask you for your password again for the next hour."
invalid_password: Invalid password
prompt: Confirm password to continue
crypto:
errors:
invalid_key: is not a valid Ed25519 or Curve25519 key
invalid_signature: is not a valid Ed25519 signature
date:
formats:
default: "%b %d, %Y"
@ -918,6 +926,7 @@ en:
on_cooldown: You have recently migrated your account. This function will become available again in %{count} days.
past_migrations: Past migrations
proceed_with_move: Move followers
redirected_msg: Your account is now redirecting to %{acct}.
redirecting_to: Your account is redirecting to %{acct}.
set_redirect: Set redirect
warning:
@ -1267,6 +1276,12 @@ en:
explanation: You requested a full backup of your Mastodon account. It's now ready for download!
subject: Your archive is ready for download
title: Archive takeout
sign_in_token:
details: 'Here are details of the attempt:'
explanation: 'We detected an attempt to sign in to your account from an unrecognized IP address. If this is you, please enter the security code below on the sign in challenge page:'
further_actions: 'If this wasn''t you, please change your password and enable two-factor authentication on your account. You can do so here:'
subject: Please confirm attempted sign in
title: Sign in attempt
warning:
explanation:
disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
@ -1304,11 +1319,14 @@ en:
title: Welcome aboard, %{name}!
users:
follow_limit_reached: You cannot follow more than %{limit} people
generic_access_help_html: Trouble accessing your account? You may get in touch with %{email} for assistance
invalid_email: The e-mail address is invalid
invalid_otp_token: Invalid two-factor code
invalid_sign_in_token: Invalid security code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
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:'
suspicious_sign_in_confirmation: You appear to not have logged in from this device before, and you haven't logged in for a while, so we're sending a security code to your e-mail address to confirm that it's you.
verification:
explanation_html: 'You can <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> attribute. The text content of the link does not matter. Here is an example:'
verification: Verification

View File

@ -151,6 +151,7 @@ en:
setting_use_blurhash: Show colorful gradients for hidden media
setting_use_pending_items: Slow mode
severity: Severity
sign_in_token_attempt: Security code
type: Import type
username: Username
username_or_email: Username or Email

View File

@ -79,6 +79,7 @@ Rails.application.routes.draw do
resource :outbox, only: [:show], module: :activitypub
resource :inbox, only: [:create], module: :activitypub
resource :claim, only: [:create], module: :activitypub
resources :collections, only: [:show], module: :activitypub
end
@ -342,6 +343,23 @@ Rails.application.routes.draw do
end
end
namespace :crypto do
resources :deliveries, only: :create
namespace :keys do
resource :upload, only: [:create]
resource :query, only: [:create]
resource :claim, only: [:create]
resource :count, only: [:show]
end
resources :encrypted_messages, only: [:index] do
collection do
post :clear
end
end
end
resources :conversations, only: [:index, :destroy] do
member do
post :read

Some files were not shown because too many files have changed in this diff Show More