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:
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
118
app/javascript/packs/two_factor_authentication.js
Normal file
118
app/javascript/packs/two_factor_authentication.js
Normal 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
@@ -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;
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
69
app/lib/activitypub/dereferencer.rb
Normal file
69
app/lib/activitypub/dereferencer.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
22
app/models/webauthn_credential.rb
Normal file
22
app/models/webauthn_credential.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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')
|
||||
17
app/views/auth/sessions/two_factor/_webauthn_form.html.haml
Normal file
17
app/views/auth/sessions/two_factor/_webauthn_form.html.haml
Normal 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')
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
44
app/views/user_mailer/webauthn_credential_added.html.haml
Normal file
44
app/views/user_mailer/webauthn_credential_added.html.haml
Normal 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')
|
||||
7
app/views/user_mailer/webauthn_credential_added.text.erb
Normal file
7
app/views/user_mailer/webauthn_credential_added.text.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= t 'devise.mailer.two_factor_enabled.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
|
||||
|
||||
=> <%= edit_user_registration_url %>
|
||||
44
app/views/user_mailer/webauthn_credential_deleted.html.haml
Normal file
44
app/views/user_mailer/webauthn_credential_deleted.html.haml
Normal 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')
|
||||
@@ -0,0 +1,7 @@
|
||||
<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
|
||||
|
||||
=> <%= edit_user_registration_url %>
|
||||
43
app/views/user_mailer/webauthn_disabled.html.haml
Normal file
43
app/views/user_mailer/webauthn_disabled.html.haml
Normal 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')
|
||||
7
app/views/user_mailer/webauthn_disabled.text.erb
Normal file
7
app/views/user_mailer/webauthn_disabled.text.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= t 'devise.mailer.webauthn_disabled.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'devise.mailer.webauthn_disabled.explanation' %>
|
||||
|
||||
=> <%= edit_user_registration_url %>
|
||||
43
app/views/user_mailer/webauthn_enabled.html.haml
Normal file
43
app/views/user_mailer/webauthn_enabled.html.haml
Normal 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')
|
||||
7
app/views/user_mailer/webauthn_enabled.text.erb
Normal file
7
app/views/user_mailer/webauthn_enabled.text.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= t 'devise.mailer.webauthn_credentia.added.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
|
||||
|
||||
=> <%= edit_user_registration_url %>
|
||||
Reference in New Issue
Block a user