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

Conflicts:
- `app/controllers/accounts_controller.rb`:
  Upstream change too close to a glitch-soc change related to
  instance-local toots. Merged upstream changes.
- `app/services/fan_out_on_write_service.rb`:
  Minor conflict due to glitch-soc's handling of Direct Messages,
  merged upstream changes.
- `yarn.lock`:
  Not really a conflict, caused by glitch-soc-only dependencies
  being textually too close to updated upstream dependencies.
  Merged upstream changes.
This commit is contained in:
Thibaut Girka
2020-08-30 16:13:08 +02:00
109 changed files with 2948 additions and 1238 deletions

View File

@@ -29,8 +29,7 @@ class AccountsController < ApplicationController
end
@pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
@statuses = filtered_status_page
@statuses = cache_collection(@statuses, Status)
@statuses = cached_filtered_status_page
@rss_url = rss_url
unless @statuses.empty?
@@ -143,8 +142,13 @@ class AccountsController < ApplicationController
request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def filtered_status_page
filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id))
def cached_filtered_status_page
cache_collection_paginated_by_id(
filtered_statuses,
Status,
PAGE_SIZE,
params_slice(:max_id, :min_id, :since_id)
)
end
def params_slice(*keys)

View File

@@ -50,8 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
return unless page_requested?
@statuses = @account.statuses.permitted_for(@account, signed_request_account)
@statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id))
@statuses = cache_collection(@statuses, Status)
@statuses = cache_collection_paginated_by_id(
@statuses,
Status,
LIMIT,
params_slice(:max_id, :min_id, :since_id)
)
end
def page_requested?

View File

@@ -22,10 +22,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def cached_account_statuses
cache_collection account_statuses, Status
end
def account_statuses
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
@@ -33,7 +29,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
cache_collection_paginated_by_id(
statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def permitted_account_statuses
@@ -41,17 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def only_media_scope
Status.where(id: account_media_status_ids)
end
def account_media_status_ids
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow.
@account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id)
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
end
def pinned_scope

View File

@@ -17,14 +17,11 @@ class Api::V1::BookmarksController < Api::BaseController
end
def cached_bookmarks
cache_collection(
Status.reorder(nil).joins(:bookmarks).merge(results),
Status
)
cache_collection(results.map(&:status), Status)
end
def results
@_results ||= account_bookmarks.paginate_by_id(
@_results ||= account_bookmarks.eager_load(:status).paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@@ -17,14 +17,11 @@ class Api::V1::FavouritesController < Api::BaseController
end
def cached_favourites
cache_collection(
Status.reorder(nil).joins(:favourites).merge(results),
Status
)
cache_collection(results.map(&:status), Status)
end
def results
@_results ||= account_favourites.paginate_by_id(
@_results ||= account_favourites.eager_load(:status).paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@@ -40,11 +40,9 @@ class Api::V1::NotificationsController < Api::BaseController
private
def load_notifications
cache_collection paginated_notifications, Notification
end
def paginated_notifications
browserable_account_notifications.paginate_by_id(
cache_collection_paginated_by_id(
browserable_account_notifications,
Notification,
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@@ -16,25 +16,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end
def load_statuses
cached_public_statuses
cached_public_statuses_page
end
def cached_public_statuses
cache_collection public_statuses, Status
end
def public_statuses
statuses = public_timeline_statuses.paginate_by_id(
def cached_public_statuses_page
cache_collection_paginated_by_id(
public_statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def public_statuses
statuses = public_timeline_statuses
statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
statuses.where(id: status_ids)
statuses.joins(:media_attachments).group(:id)
else
statuses
end

View File

@@ -20,25 +20,18 @@ class Api::V1::Timelines::TagController < Api::BaseController
end
def cached_tagged_statuses
cache_collection tagged_statuses, Status
end
def tagged_statuses
if @tag.nil?
[]
else
statuses = tag_timeline_statuses.paginate_by_id(
statuses = tag_timeline_statuses
statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
cache_collection_paginated_by_id(
statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
statuses.where(id: status_ids)
else
statuses
end
end
end

View File

@@ -39,6 +39,22 @@ class Auth::SessionsController < Devise::SessionsController
store_location_for(:user, tmp_stored_location) if continue_after?
end
def webauthn_options
user = find_user
if user.webauthn_enabled?
options_for_get = WebAuthn::Credential.options_for_get(
allow: user.webauthn_credentials.pluck(:external_id)
)
session[:webauthn_challenge] = options_for_get.challenge
render json: options_for_get, status: :ok
else
render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized
end
end
protected
def find_user
@@ -53,7 +69,7 @@ class Auth::SessionsController < Devise::SessionsController
end
def user_params
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
end
def after_sign_in_path_for(resource)

View File

@@ -47,4 +47,8 @@ module CacheConcern
raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
end
def cache_collection_paginated_by_id(raw, klass, limit, options)
cache_collection raw.cache_ids.paginate_by_id(limit, options), klass
end
end

View File

@@ -7,6 +7,44 @@ module SignatureVerification
include DomainControlHelper
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
class SignatureVerificationError < StandardError; end
class SignatureParamsParser < Parslet::Parser
rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
# qdtext and quoted_pair are not exactly according to spec but meh
rule(:qdtext) { match('[^\\\\"]') }
rule(:quoted_pair) { str('\\') >> any }
rule(:bws) { match('\s').repeat }
rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
rule(:comma) { bws >> str(',') >> bws }
# Old versions of node-http-signature add an incorrect "Signature " prefix to the header
rule(:buggy_prefix) { str('Signature ') }
rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
root(:params)
end
class SignatureParamsTransformer < Parslet::Transform
rule(params: subtree(:p)) do
(p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
end
rule(param: { key: simple(:key), value: simple(:val) }) do
[key, val]
end
rule(quoted_string: simple(:string)) do
string.to_s
end
rule(token: simple(:string)) do
string.to_s
end
end
def require_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
@@ -24,72 +62,40 @@ module SignatureVerification
end
def signature_key_id
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
signature_params['keyId']
rescue SignatureVerificationError
nil
end
def signed_request_account
return @signed_request_account if defined?(@signed_request_account)
unless signed_request?
@signature_verification_failure_reason = 'Request not signed'
@signed_request_account = nil
return
end
raise SignatureVerificationError, 'Request not signed' unless signed_request?
raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
if request.headers['Date'].present? && !matches_time_window?
@signature_verification_failure_reason = 'Signed request date outside acceptable time window'
@signed_request_account = nil
return
end
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
if incompatible_signature?(signature_params)
@signature_verification_failure_reason = 'Incompatible request signature'
@signed_request_account = nil
return
end
verify_signature_strength!
account = account_from_key_id(signature_params['keyId'])
if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil
return
end
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string(signature_params['headers'])
compare_signed_string = build_signed_string
return account unless verify_signature(account, signature, compare_signed_string).nil?
account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil
return
end
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
return account unless verify_signature(account, signature, compare_signed_string).nil?
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
@signed_request_account = nil
rescue SignatureVerificationError => e
@signature_verification_failure_reason = e.message
@signed_request_account = nil
end
@@ -99,6 +105,31 @@ module SignatureVerification
private
def signature_params
@signature_params ||= begin
raw_signature = request.headers['Signature']
tree = SignatureParamsParser.new.parse(raw_signature)
SignatureParamsTransformer.new.apply(tree)
end
rescue Parslet::ParseFailed
raise SignatureVerificationError, 'Error parsing signature parameters'
end
def signature_algorithm
signature_params.fetch('algorithm', 'hs2019')
end
def signed_headers
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
end
def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end
def verify_signature(account, signature, compare_signed_string)
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@@ -108,12 +139,20 @@ module SignatureVerification
nil
end
def build_signed_string(signed_headers)
signed_headers = 'date' if signed_headers.blank?
signed_headers.downcase.split(' ').map do |signed_header|
def build_signed_string
signed_headers.map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
"(created): #{signature_params['created']}"
elsif signed_header == '(expires)'
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
"(expires): #{signature_params['expires']}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else
@@ -123,13 +162,28 @@ module SignatureVerification
end
def matches_time_window?
created_time = nil
expires_time = nil
begin
time_sent = Time.httpdate(request.headers['Date'])
if signature_algorithm == 'hs2019' && signature_params['created'].present?
created_time = Time.at(signature_params['created'].to_i).utc
elsif request.headers['Date'].present?
created_time = Time.httpdate(request.headers['Date']).utc
end
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError
return false
end
(Time.now.utc - time_sent).abs <= 12.hours
expires_time ||= created_time + 5.minutes unless created_time.nil?
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
true
end
def body_digest
@@ -140,9 +194,8 @@ module SignatureVerification
name.split(/-/).map(&:capitalize).join('-')
end
def incompatible_signature?(signature_params)
signature_params['keyId'].blank? ||
signature_params['signature'].blank?
def missing_required_signature_parameters?
signature_params['keyId'].blank? || signature_params['signature'].blank?
end
def account_from_key_id(key_id)

View File

@@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
end
def two_factor_enabled?
find_user&.otp_required_for_login?
find_user&.two_factor_enabled?
end
def valid_webauthn_credential?(user, webauthn_credential)
user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id)
begin
webauthn_credential.verify(
session[:webauthn_challenge],
public_key: user_credential.public_key,
sign_count: user_credential.sign_count
)
user_credential.update!(sign_count: webauthn_credential.sign_count)
rescue WebAuthn::Error
false
end
end
def valid_otp_attempt?(user)
@@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
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)
if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
authenticate_with_two_factor_via_webauthn(user)
elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_via_otp(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)
def authenticate_with_two_factor_via_webauthn(user)
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
if valid_webauthn_credential?(user, webauthn_credential)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
render json: { redirect_path: root_path }, status: :ok
else
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
end
end
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
@@ -44,6 +75,12 @@ module TwoFactorAuthenticationConcern
session[:attempt_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn'
else
'totp'
end
render :two_factor
end
end

View File

@@ -18,18 +18,21 @@ module Settings
end
def create
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt])
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret])
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
current_user.otp_required_for_login = true
current_user.otp_secret = session[:new_otp_secret]
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!
UserMailer.two_factor_enabled(current_user).deliver_later!
session.delete(:new_otp_secret)
render 'settings/two_factor_authentication/recovery_codes/index'
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
prepare_two_factor_form
render :new
end
@@ -43,12 +46,15 @@ module Settings
def prepare_two_factor_form
@confirmation = Form::TwoFactorConfirmation.new
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
@new_otp_secret = session[:new_otp_secret]
@provision_url = current_user.otp_provisioning_uri(current_user.email,
otp_secret: @new_otp_secret,
issuer: Rails.configuration.x.local_domain)
@qrcode = RQRCode::QRCode.new(@provision_url)
end
def ensure_otp_secret
redirect_to settings_two_factor_authentication_path unless current_user.otp_secret
redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank?
end
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class OtpAuthenticationController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_not_enabled, only: [:show]
before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
session[:new_otp_secret] = User.generate_otp_secret(32)
redirect_to new_settings_two_factor_authentication_confirmation_path
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def verify_otp_not_enabled
redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled?
end
def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end
end

View File

@@ -0,0 +1,103 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class WebauthnCredentialsController < BaseController
layout 'admin'
before_action :authenticate_user!
before_action :require_otp_enabled
before_action :require_webauthn_enabled, only: [:index, :destroy]
def new; end
def index; end
def options
current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id
options_for_create = WebAuthn::Credential.options_for_create(
user: {
name: current_user.account.username,
display_name: current_user.account.username,
id: current_user.webauthn_id,
},
exclude: current_user.webauthn_credentials.pluck(:external_id)
)
session[:webauthn_challenge] = options_for_create.challenge
render json: options_for_create, status: :ok
end
def create
webauthn_credential = WebAuthn::Credential.from_create(params[:credential])
if webauthn_credential.verify(session[:webauthn_challenge])
user_credential = current_user.webauthn_credentials.build(
external_id: webauthn_credential.id,
public_key: webauthn_credential.public_key,
nickname: params[:nickname],
sign_count: webauthn_credential.sign_count
)
if user_credential.save
flash[:success] = I18n.t('webauthn_credentials.create.success')
status = :ok
if current_user.webauthn_credentials.size == 1
UserMailer.webauthn_enabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :internal_server_error
end
else
flash[:error] = t('webauthn_credentials.create.error')
status = :unauthorized
end
render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status
end
def destroy
credential = current_user.webauthn_credentials.find_by(id: params[:id])
if credential
credential.destroy
if credential.destroyed?
flash[:success] = I18n.t('webauthn_credentials.destroy.success')
if current_user.webauthn_credentials.empty?
UserMailer.webauthn_disabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
redirect_to settings_two_factor_authentication_methods_path
end
private
def require_otp_enabled
unless current_user.otp_enabled?
flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end
end
def require_webauthn_enabled
unless current_user.webauthn_enabled?
flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end
end
end
end
end

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Settings
class TwoFactorAuthenticationMethodsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :require_challenge!, only: :disable
before_action :require_otp_enabled
skip_before_action :require_functional!
def index; end
def disable
current_user.disable_two_factor!
UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') }
end
private
def require_otp_enabled
redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
end
end
end

View File

@@ -1,53 +0,0 @@
# frozen_string_literal: true
module Settings
class TwoFactorAuthenticationsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
current_user.otp_secret = User.generate_otp_secret(32)
current_user.save!
redirect_to new_settings_two_factor_authentication_confirmation_path
end
def destroy
if acceptable_code?
current_user.otp_required_for_login = false
current_user.save!
UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@confirmation = Form::TwoFactorConfirmation.new
render :show
end
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def verify_otp_required
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
end
def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end

View File

@@ -205,7 +205,7 @@ export default class Dropdown extends React.PureComponent {
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}
this.props.onClose(this.state.id);

View File

@@ -54,8 +54,6 @@ export default class GIFV extends React.PureComponent {
<video
src={src}
width={width}
height={height}
role='button'
tabIndex='0'
aria-label={alt}

View File

@@ -7,6 +7,7 @@ import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, isStaff } from '../initial_state';
import classNames from 'classnames';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -20,7 +21,7 @@ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@@ -329,7 +330,7 @@ class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}

View File

@@ -179,7 +179,7 @@ class PrivacyDropdown extends React.PureComponent {
} else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus();
this.activeElement.focus({ preventScroll: true });
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
@@ -220,7 +220,7 @@ class PrivacyDropdown extends React.PureComponent {
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
}

View File

@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me, isStaff } from '../../../initial_state';
import classNames from 'classnames';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -14,7 +15,7 @@ const messages = defineMessages({
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@@ -273,7 +274,7 @@ class ActionBar extends React.PureComponent {
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Споделяне",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} сподели",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Toud spilhennet",
"status.read_more": "Lenn muioc'h",
"status.reblog": "Skignañ",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -421,7 +421,7 @@
"id": "status.reblog"
},
{
"defaultMessage": "Boost to original audience",
"defaultMessage": "Boost with original visibility",
"id": "status.reblog_private"
},
{
@@ -2421,7 +2421,7 @@
"id": "status.reblog"
},
{
"defaultMessage": "Boost to original audience",
"defaultMessage": "Boost with original visibility",
"id": "status.reblog_private"
},
{

View File

@@ -394,7 +394,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "הדהוד",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "הודהד על ידי {name}",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "बूस्ट",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Podigni",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} je podigao",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Repetar",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} repetita",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Tijewwiqin yettwasentḍen",
"status.read_more": "Issin ugar",
"status.reblog": "Bḍu",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "Yebḍa-tt {name}",
"status.reblogs.empty": "Ula yiwen ur yebḍi tajewwiqt-agi ar tura. Ticki yebḍa-tt yiwen, ad d-iban da.",
"status.redraft": "Kkes tɛiwdeḍ tira",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Podrži",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} podržao(la)",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -389,7 +389,7 @@
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",

View File

@@ -112,11 +112,10 @@ const sharedCallbacks = {
},
disconnected () {
subscriptions.forEach(({ onDisconnect }) => onDisconnect());
subscriptions.forEach(subscription => unsubscribe(subscription));
},
reconnected () {
subscriptions.forEach(subscription => subscribe(subscription));
},
};
@@ -252,15 +251,8 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
let firstConnect = true;
es.onopen = () => {
if (firstConnect) {
firstConnect = false;
connected();
} else {
reconnected();
}
connected();
};
KNOWN_EVENT_TYPES.forEach(type => {

View File

@@ -0,0 +1,118 @@
import axios from 'axios';
import * as WebAuthnJSON from '@github/webauthn-json';
import ready from '../mastodon/ready';
import 'regenerator-runtime/runtime';
function getCSRFToken() {
var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
if (CSRFSelector) {
return CSRFSelector.getAttribute('content');
} else {
return null;
}
}
function hideFlashMessages() {
Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
flashMessage.classList.add('hidden');
});
}
function callback(url, body) {
axios.post(url, JSON.stringify(body), {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': getCSRFToken(),
},
credentials: 'same-origin',
}).then(function(response) {
window.location.replace(response.data.redirect_path);
}).catch(function(error) {
if (error.response.status === 422) {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error.response.data.error);
} else {
console.error(error);
}
});
}
ready(() => {
if (!WebAuthnJSON.supported()) {
const unsupported_browser_message = document.getElementById('unsupported-browser-message');
if (unsupported_browser_message) {
unsupported_browser_message.classList.remove('hidden');
document.querySelector('.btn.js-webauthn').disabled = true;
}
}
const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
if (webAuthnCredentialRegistrationForm) {
webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
event.preventDefault();
var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
if (nickname.value) {
axios.get('/settings/security_keys/options')
.then((response) => {
const credentialOptions = response.data;
WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
var params = { 'credential': credential, 'nickname': nickname.value };
callback('/settings/security_keys', params);
}).catch((error) => {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error);
});
}).catch((error) => {
console.error(error.response.data.error);
});
} else {
nickname.focus();
}
});
}
const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
if (webAuthnCredentialAuthenticationForm) {
webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
event.preventDefault();
axios.get('sessions/security_key_options')
.then((response) => {
const credentialOptions = response.data;
WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
var params = { 'user': { 'credential': credential } };
callback('sign_in', params);
}).catch((error) => {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error);
});
}).catch((error) => {
console.error(error.response.data.error);
});
});
const otpAuthenticationForm = document.getElementById('otp-authentication-form');
const linkToOtp = document.getElementById('link-to-otp');
linkToOtp.addEventListener('click', () => {
webAuthnCredentialAuthenticationForm.classList.add('hidden');
otpAuthenticationForm.classList.remove('hidden');
hideFlashMessages();
});
const linkToWebAuthn = document.getElementById('link-to-webauthn');
linkToWebAuthn.addEventListener('click', () => {
otpAuthenticationForm.classList.add('hidden');
webAuthnCredentialAuthenticationForm.classList.remove('hidden');
hideFlashMessages();
});
}
});

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,10 @@ code {
}
.simple_form {
&.hidden {
display: none;
}
.input {
margin-bottom: 15px;
overflow: hidden;
@@ -100,6 +104,14 @@ code {
}
}
.title {
color: #d9e1e8;
font-size: 20px;
line-height: 28px;
font-weight: 400;
margin-bottom: 30px;
}
.hint {
color: $darker-text-color;
@@ -142,7 +154,7 @@ code {
}
}
.otp-hint {
.authentication-hint {
margin-bottom: 25px;
}
@@ -592,6 +604,10 @@ code {
color: $error-value-color;
}
&.hidden {
display: none;
}
a {
display: inline-block;
color: $darker-text-color;

View File

@@ -71,7 +71,15 @@ class ActivityPub::Activity
end
def object_uri
@object_uri ||= value_or_id(@object)
@object_uri ||= begin
str = value_or_id(@object)
if str.start_with?('bear:')
Addressable::URI.parse(str).query_values['u']
else
str
end
end
end
def unsupported_object_type?
@@ -159,20 +167,20 @@ class ActivityPub::Activity
def dereference_object!
return unless @object.is_a?(String)
return if invalid_origin?(@object)
object = fetch_resource(@object, true, signed_fetch_account)
return unless object.present? && object.is_a?(Hash) && supported_context?(object)
dereferencer = ActivityPub::Dereferencer.new(@object, permitted_origin: @account.uri, signature_account: signed_fetch_account)
@object = object
@object = dereferencer.object unless dereferencer.object.nil?
end
def signed_fetch_account
return Account.find(@options[:delivered_to_account_id]) if @options[:delivered_to_account_id].present?
first_mentioned_local_account || first_local_follower
end
def first_mentioned_local_account
audience = (as_array(@json['to']) + as_array(@json['cc'])).uniq
audience = (as_array(@json['to']) + as_array(@json['cc'])).map { |x| value_or_id(x) }.uniq
local_usernames = audience.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }

View File

@@ -34,12 +34,20 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
private
def audience_to
as_array(@json['to']).map { |x| value_or_id(x) }
end
def audience_cc
as_array(@json['cc']).map { |x| value_or_id(x) }
end
def visibility_from_audience
if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public])
if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:public
elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:unlisted
elsif equals_or_includes?(@json['to'], @account.followers_url)
elsif audience_to.include?(@account.followers_url)
:private
else
:direct

View File

@@ -15,7 +15,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
private
def create_encrypted_message
return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank?
return reject_payload! if invalid_origin?(object_uri) || @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'))
@@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def create_status
return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@@ -65,11 +65,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def audience_to
@object['to'] || @json['to']
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
end
def audience_cc
@object['cc'] || @json['cc']
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
end
def process_status
@@ -90,7 +90,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
fetch_replies(@status)
check_for_spam
distribute(@status)
forward_for_reply if @status.distributable?
forward_for_reply
end
def find_existing_status
@@ -102,8 +102,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status_params
@params = begin
{
uri: @object['id'],
url: object_url || @object['id'],
uri: object_uri,
url: object_url || object_uri,
account: @account,
text: text_from_content || '',
language: detected_language,
@@ -122,7 +122,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def process_audience
(as_array(audience_to) + as_array(audience_cc)).uniq.each do |audience|
(audience_to + audience_cc).uniq.each do |audience|
next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
# Unlike with tags, there is no point in resolving accounts we don't already
@@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
RedisLock.acquire(poll_lock_options) do |lock|
if lock.acquired?
already_voted = poll.votes.where(account: @account).exists?
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: @object['id'])
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
else
raise Mastodon::RaceConditionError
end
@@ -352,11 +352,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def visibility_from_audience
if equals_or_includes?(audience_to, ActivityPub::TagManager::COLLECTIONS[:public])
if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:public
elsif equals_or_includes?(audience_cc, ActivityPub::TagManager::COLLECTIONS[:public])
elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:unlisted
elsif equals_or_includes?(audience_to, @account.followers_url)
elsif audience_to.include?(@account.followers_url)
:private
elsif direct_message == false
:limited
@@ -367,7 +367,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def audience_includes?(account)
uri = ActivityPub::TagManager.instance.uri_for(account)
equals_or_includes?(audience_to, uri) || equals_or_includes?(audience_cc, uri)
audience_to.include?(uri) || audience_cc.include?(uri)
end
def direct_message
@@ -391,7 +391,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def text_from_content
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type?
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
if @object['content'].present?
@object['content']
@@ -483,19 +483,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def addresses_local_accounts?
return true if @options[:delivered_to_account_id]
local_usernames = (as_array(audience_to) + as_array(audience_cc)).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
local_usernames = (audience_to + audience_cc).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists?
end
def tombstone_exists?
Tombstone.exists?(uri: object_uri)
end
def check_for_spam
SpamCheck.perform(@status)
end
def forward_for_reply
return unless @json['signature'].present? && reply_to_local?
return unless @status.distributable? && @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
end
@@ -513,7 +517,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def lock_options
{ redis: Redis.current, key: "create:#{@object['id']}" }
{ redis: Redis.current, key: "create:#{object_uri}" }
end
def poll_lock_options

View File

@@ -0,0 +1,69 @@
# frozen_string_literal: true
class ActivityPub::Dereferencer
include JsonLdHelper
def initialize(uri, permitted_origin: nil, signature_account: nil)
@uri = uri
@permitted_origin = permitted_origin
@signature_account = signature_account
end
def object
@object ||= fetch_object!
end
private
def bear_cap?
@uri.start_with?('bear:')
end
def fetch_object!
if bear_cap?
fetch_with_token!
else
fetch_with_signature!
end
end
def fetch_with_token!
perform_request(bear_cap['u'], headers: { 'Authorization' => "Bearer #{bear_cap['t']}" })
end
def fetch_with_signature!
perform_request(@uri)
end
def bear_cap
@bear_cap ||= Addressable::URI.parse(@uri).query_values
end
def perform_request(uri, headers: nil)
return if invalid_origin?(uri)
req = Request.new(:get, uri)
req.add_headers('Accept' => 'application/activity+json, application/ld+json')
req.add_headers(headers) if headers
req.on_behalf_of(@signature_account) if @signature_account
req.perform do |res|
if res.code == 200
json = body_to_json(res.body_with_limit)
json if json.present? && json['id'] == uri
else
raise Mastodon::UnexpectedResponseError, res unless response_successful?(res) || response_error_unsalvageable?(res)
end
end
end
def invalid_origin?(uri)
return true if unsupported_uri_scheme?(uri)
needle = Addressable::URI.parse(uri).host
haystack = Addressable::URI.parse(@permitted_origin).host
!haystack.casecmp(needle).zero?
end
end

View File

@@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
end
end
def webauthn_enabled(user, **)
@resource = user
@instance = Rails.configuration.x.local_domain
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
end
end
def webauthn_disabled(user, **)
@resource = user
@instance = Rails.configuration.x.local_domain
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
end
end
def webauthn_credential_added(user, webauthn_credential)
@resource = user
@instance = Rails.configuration.x.local_domain
@webauthn_credential = webauthn_credential
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
end
end
def webauthn_credential_deleted(user, webauthn_credential)
@resource = user
@instance = Rails.configuration.x.local_domain
@webauthn_credential = webauthn_credential
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
end
end
def welcome(user)
@resource = user
@instance = Rails.configuration.x.local_domain

View File

@@ -338,7 +338,7 @@ class MediaAttachment < ApplicationRecord
raise Mastodon::StreamValidationError, 'Video has no video stream' if movie.width.nil? || movie.frame_rate.nil?
raise Mastodon::DimensionsValidationError, "#{movie.width}x#{movie.height} videos are not supported" if movie.width * movie.height > MAX_VIDEO_MATRIX_LIMIT
raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.to_i}fps videos are not supported" if movie.frame_rate > MAX_VIDEO_FRAME_RATE
raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.floor}fps videos are not supported" if movie.frame_rate.floor > MAX_VIDEO_FRAME_RATE
end
def set_meta

View File

@@ -40,6 +40,7 @@
# approved :boolean default(TRUE), not null
# sign_in_token :string
# sign_in_token_sent_at :datetime
# webauthn_id :string
#
class User < ApplicationRecord
@@ -77,6 +78,7 @@ class User < ApplicationRecord
has_many :backups, inverse_of: :user
has_many :invites, inverse_of: :user
has_many :markers, inverse_of: :user, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@@ -198,9 +200,25 @@ class User < ApplicationRecord
prepare_returning_user!
end
def otp_enabled?
otp_required_for_login
end
def webauthn_enabled?
webauthn_credentials.any?
end
def two_factor_enabled?
otp_required_for_login? || webauthn_credentials.any?
end
def disable_two_factor!
self.otp_required_for_login = false
self.otp_secret = nil
otp_backup_codes&.clear
webauthn_credentials.destroy_all if webauthn_enabled?
save!
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webauthn_credentials
#
# id :bigint(8) not null, primary key
# external_id :string not null
# public_key :string not null
# nickname :string not null
# sign_count :bigint(8) default(0), not null
# user_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class WebauthnCredential < ApplicationRecord
validates :external_id, :public_key, :nickname, :sign_count, presence: true
validates :external_id, uniqueness: true
validates :nickname, uniqueness: { scope: :user_id }
validates :sign_count,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
end

View File

@@ -8,8 +8,6 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local?
render_anonymous_payload(status)
if status.direct_visibility?
deliver_to_mentioned_followers(status)
deliver_to_direct_timelines(status)
@@ -24,6 +22,8 @@ class FanOutOnWriteService < BaseService
return if status.account.silenced? || !status.public_visibility?
return if status.reblog? && !Setting.show_reblogs_in_public_timelines
render_anonymous_payload(status)
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
@@ -63,8 +63,10 @@ class FanOutOnWriteService < BaseService
def deliver_to_mentioned_followers(status)
Rails.logger.debug "Delivering status #{status.id} to limited followers"
FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? && mentioned_account.following?(status.account) }) do |follower|
[status.id, follower.id, :home]
status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id).reorder(nil).find_in_batches do |followers|
FeedInsertWorker.push_bulk(followers) do |follower|
[status.id, follower.id, :home]
end
end
end

View File

@@ -8,7 +8,7 @@ class HashtagQueryService < BaseService
all = tags_for(params[:all])
none = tags_for(params[:none])
Status.distinct
Status.group(:id)
.as_tag_timeline(tags, account, local)
.tagged_with_all(all)
.tagged_with_none(none)

View File

@@ -1,14 +1,9 @@
- 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('simple_form.hints.sessions.otp')
=javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
.fields-group
= f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
- if @webauthn_enabled
= render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
.actions
= f.button :button, t('auth.login'), type: :submit
- if Setting.site_contact_email.present?
%p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
= render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }

View File

@@ -0,0 +1,18 @@
= simple_form_for(resource,
as: resource_name,
url: session_path(resource_name),
html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
%p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
.fields-group
= f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_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.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
- if @webauthn_enabled
.form-footer
= link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn')

View File

@@ -0,0 +1,17 @@
%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
= simple_form_for(resource,
as: resource_name,
url: session_path(resource_name),
html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
%h3.title= t('simple_form.title.sessions.webauthn')
%p.hint= t('simple_form.hints.sessions.webauthn')
.actions
= f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit
.form-footer
%p= t('auth.dont_have_your_security_key')
= link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp')

View File

@@ -2,17 +2,17 @@
= t('settings.two_factor_authentication')
= simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f|
%p.hint= t('two_factor_authentication.instructions_html')
%p.hint= t('otp_authentication.instructions_html')
.qr-wrapper
.qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
.qr-alternative
%p.hint= t('two_factor_authentication.manual_instructions')
%samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ')
%p.hint= t('otp_authentication.manual_instructions')
%samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group
= f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
= f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
.actions
= f.button :button, t('two_factor_authentication.enable'), type: :submit
= f.button :button, t('otp_authentication.enable'), type: :submit

View File

@@ -0,0 +1,9 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
.simple_form
%p.hint= t('otp_authentication.description_html')
%hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'

View File

@@ -0,0 +1,17 @@
- content_for :page_title do
= t('settings.webauthn_authentication')
.table-wrapper
%table.table
%tbody
- current_user.webauthn_credentials.each do |credential|
%tr
%td= credential.nickname
%td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name))
%td
= table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') }
%hr.spacer/
.simple_form
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'

View File

@@ -0,0 +1,16 @@
- content_for :page_title do
= t('settings.webauthn_authentication')
= simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f|
%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
%p.hint= t('webauthn_credentials.description_html')
.fields_group
= f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
.actions
= f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'

View File

@@ -0,0 +1,41 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
- content_for :heading_actions do
= link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
%p.hint
%span.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
.table-wrapper
%table.table
%thead
%tr
%th= t('two_factor_authentication.methods')
%th
%tbody
%tr
%td= t('two_factor_authentication.otp')
%td
= table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post
%tr
%td= t('two_factor_authentication.webauthn')
- if current_user.webauthn_enabled?
%td
= table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get
- else
%td
= table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get
%hr.spacer/
%h3= t('two_factor_authentication.recovery_codes')
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'

View File

@@ -1,36 +0,0 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
- if current_user.otp_required_for_login
%p.hint
%span.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
%hr.spacer/
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
.fields-group
= f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
.actions
= f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
%hr.spacer/
%h3= t('two_factor_authentication.recovery_codes')
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
- else
.simple_form
%p.hint= t('two_factor_authentication.description_html')
%hr.spacer/
= link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'

View File

@@ -0,0 +1,44 @@
%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_credential.added.title'
%p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:"
%p.lead= @webauthn_credential.nickname
%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.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,7 @@
<%= t 'devise.mailer.two_factor_enabled.title' %>
===
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
=> <%= edit_user_registration_url %>

View File

@@ -0,0 +1,44 @@
%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_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_credential.deleted.title'
%p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:"
%p.lead= @webauthn_credential.nickname
%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.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,7 @@
<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
===
<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
=> <%= edit_user_registration_url %>

View File

@@ -0,0 +1,43 @@
%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_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_disabled.title'
%p.lead= t 'devise.mailer.webauthn_disabled.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.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,7 @@
<%= t 'devise.mailer.webauthn_disabled.title' %>
===
<%= t 'devise.mailer.webauthn_disabled.explanation' %>
=> <%= edit_user_registration_url %>

View File

@@ -0,0 +1,43 @@
%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_enabled.title'
%p.lead= t 'devise.mailer.webauthn_enabled.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.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,7 @@
<%= t 'devise.mailer.webauthn_credentia.added.title' %>
===
<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
=> <%= edit_user_registration_url %>