Merge commit '5b291fcbe41564264954618fb1f4086a3be1c600' into glitch-soc/merge-upstream

Conflicts:
- `app/validators/poll_options_validator.rb`:
  Upstream split `PollValidator` in two, and glitch-soc had local changes to
  make the options configurable.
  Refactored as upstream did, keeping glitch-soc's configurable limits.
This commit is contained in:
Claire 2025-01-28 18:13:56 +01:00
commit 353e5f797c
26 changed files with 222 additions and 143 deletions

View File

@ -370,7 +370,7 @@ GEM
marcel (~> 1.0.1) marcel (~> 1.0.1)
mime-types mime-types
terrapin (>= 0.6.0, < 2.0) terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.4)
launchy (3.0.1) launchy (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) childprocess (~> 5.0)
@ -716,7 +716,7 @@ GEM
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.13.2) rspec-support (3.13.2)
rubocop (1.70.0) rubocop (1.71.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
@ -726,14 +726,14 @@ GEM
rubocop-ast (>= 1.36.2, < 2.0) rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0) rubocop-ast (1.38.0)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-capybara (2.21.0) rubocop-capybara (2.21.0)
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-performance (1.23.1) rubocop-performance (1.23.1)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.28.0) rubocop-rails (2.29.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0) rubocop (>= 1.52.0, < 2.0)

View File

@ -117,7 +117,7 @@ module SignatureVerification
def verify_signature_strength! 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 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 Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !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') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end end
@ -155,14 +155,14 @@ module SignatureVerification
def build_signed_string(include_query_string: true) def build_signed_string(include_query_string: true)
signed_headers.map do |signed_header| signed_headers.map do |signed_header|
case signed_header case signed_header
when Request::REQUEST_TARGET when HttpSignatureDraft::REQUEST_TARGET
if include_query_string if include_query_string
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
else else
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
# Therefore, temporarily support such incorrect signatures for compatibility. # Therefore, temporarily support such incorrect signatures for compatibility.
# TODO: remove eventually some time after release of the fixed version # TODO: remove eventually some time after release of the fixed version
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end end
when '(created)' when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'

View File

@ -86,6 +86,13 @@
"alert.unexpected.message": "Objevila se neočekávaná chyba.", "alert.unexpected.message": "Objevila se neočekávaná chyba.",
"alert.unexpected.title": "Jejda!", "alert.unexpected.title": "Jejda!",
"alt_text_badge.title": "Popisek", "alt_text_badge.title": "Popisek",
"alt_text_modal.add_alt_text": "Přidat alt text",
"alt_text_modal.add_text_from_image": "Přidat text z obrázku",
"alt_text_modal.cancel": "Zrušit",
"alt_text_modal.change_thumbnail": "Změnit miniaturu",
"alt_text_modal.describe_for_people_with_hearing_impairments": "Popište to pro osoby se sluchovým postižením…",
"alt_text_modal.describe_for_people_with_visual_impairments": "Popište to pro osoby se zrakovým postižením…",
"alt_text_modal.done": "Hotovo",
"announcement.announcement": "Oznámení", "announcement.announcement": "Oznámení",
"annual_report.summary.archetype.booster": "Lovec obsahu", "annual_report.summary.archetype.booster": "Lovec obsahu",
"annual_report.summary.archetype.lurker": "Špión", "annual_report.summary.archetype.lurker": "Špión",
@ -407,6 +414,8 @@
"ignore_notifications_modal.not_followers_title": "Ignorovat oznámení od lidí, kteří vás nesledují?", "ignore_notifications_modal.not_followers_title": "Ignorovat oznámení od lidí, kteří vás nesledují?",
"ignore_notifications_modal.not_following_title": "Ignorovat oznámení od lidí, které nesledujete?", "ignore_notifications_modal.not_following_title": "Ignorovat oznámení od lidí, které nesledujete?",
"ignore_notifications_modal.private_mentions_title": "Ignorovat oznámení z nevyžádaných soukromých zmínek?", "ignore_notifications_modal.private_mentions_title": "Ignorovat oznámení z nevyžádaných soukromých zmínek?",
"info_button.label": "Nápověda",
"info_button.what_is_alt_text": "<h1>Co je to alt text?</h1> <p>Alt text poskytuje popis obrázků pro lidi se zrakovými postižením, špatným připojením něbo těm, kteří potřebují více kontextu.</p> <p>Můžete zlepšit přístupnost a porozumění napsáním jasného, stručného a objektivního alt textu.</p> <ul> <li>Zachyťte důležité prvky</li> <li>Shrňte text v obrázku</li> <li>Použijte pravidelnou větnou skladbu</li> <li>Vyhněte se nadbytečným informacím</li> <li>U komplexních vizualizací (diagramy, mapy...) se zaměřte na trendy a klíčová zjištění</li> </ul>",
"interaction_modal.action.favourite": "Chcete-li pokračovat, musíte oblíbit z vašeho účtu.", "interaction_modal.action.favourite": "Chcete-li pokračovat, musíte oblíbit z vašeho účtu.",
"interaction_modal.action.follow": "Chcete-li pokračovat, musíte sledovat z vašeho účtu.", "interaction_modal.action.follow": "Chcete-li pokračovat, musíte sledovat z vašeho účtu.",
"interaction_modal.action.reblog": "Chcete-li pokračovat, musíte dát boost z vašeho účtu.", "interaction_modal.action.reblog": "Chcete-li pokračovat, musíte dát boost z vašeho účtu.",

View File

@ -28,7 +28,7 @@
"account.enable_notifications": "Notificarme cuando @{name} publique algo", "account.enable_notifications": "Notificarme cuando @{name} publique algo",
"account.endorse": "Destacar en mi perfil", "account.endorse": "Destacar en mi perfil",
"account.featured_tags.last_status_at": "Última publicación el {date}", "account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "No hay publicaciones", "account.featured_tags.last_status_never": "Sin publicaciones",
"account.featured_tags.title": "Etiquetas destacadas de {name}", "account.featured_tags.title": "Etiquetas destacadas de {name}",
"account.follow": "Seguir", "account.follow": "Seguir",
"account.follow_back": "Seguir también", "account.follow_back": "Seguir también",
@ -146,7 +146,7 @@
"column.about": "Acerca de", "column.about": "Acerca de",
"column.blocks": "Usuarios bloqueados", "column.blocks": "Usuarios bloqueados",
"column.bookmarks": "Marcadores", "column.bookmarks": "Marcadores",
"column.community": "Línea de tiempo local", "column.community": "Cronología local",
"column.create_list": "Crear lista", "column.create_list": "Crear lista",
"column.direct": "Menciones privadas", "column.direct": "Menciones privadas",
"column.directory": "Buscar perfiles", "column.directory": "Buscar perfiles",

View File

@ -414,6 +414,7 @@
"ignore_notifications_modal.not_followers_title": "나를 팔로우하지 않는 사람들의 알림을 무시할까요?", "ignore_notifications_modal.not_followers_title": "나를 팔로우하지 않는 사람들의 알림을 무시할까요?",
"ignore_notifications_modal.not_following_title": "내가 팔로우하지 않는 사람들의 알림을 무시할까요?", "ignore_notifications_modal.not_following_title": "내가 팔로우하지 않는 사람들의 알림을 무시할까요?",
"ignore_notifications_modal.private_mentions_title": "요청하지 않은 개인 멘션 알림을 무시할까요?", "ignore_notifications_modal.private_mentions_title": "요청하지 않은 개인 멘션 알림을 무시할까요?",
"info_button.label": "도움말",
"interaction_modal.action.favourite": "계속하려면 내 계정으로 즐겨찾기해야 합니다.", "interaction_modal.action.favourite": "계속하려면 내 계정으로 즐겨찾기해야 합니다.",
"interaction_modal.action.follow": "계속하려면 내 계정으로 팔로우해야 합니다.", "interaction_modal.action.follow": "계속하려면 내 계정으로 팔로우해야 합니다.",
"interaction_modal.action.reblog": "계속하려면 내 계정으로 리블로그해야 합니다.", "interaction_modal.action.reblog": "계속하려면 내 계정으로 리블로그해야 합니다.",

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# This implements an older draft of HTTP Signatures:
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures
class HttpSignatureDraft
REQUEST_TARGET = '(request-target)'
def initialize(keypair, key_id, full_path: true)
@keypair = keypair
@key_id = key_id
@full_path = full_path
end
def request_target(verb, url)
if url.query.nil? || !@full_path
"#{verb} #{url.path}"
else
"#{verb} #{url.path}?#{url.query}"
end
end
def sign(signed_headers, verb, url)
signed_headers = signed_headers.merge(REQUEST_TARGET => request_target(verb, url))
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{@key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end
end

View File

@ -61,8 +61,6 @@ class PerOperationWithDeadline < HTTP::Timeout::PerOperation
end end
class Request class Request
REQUEST_TARGET = '(request-target)'
# We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening # We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening
# and 5s timeout on the TLS handshake, meaning the worst case should take # and 5s timeout on the TLS handshake, meaning the worst case should take
# about 15s in total # about 15s in total
@ -78,11 +76,18 @@ class Request
@http_client = options.delete(:http_client) @http_client = options.delete(:http_client)
@allow_local = options.delete(:allow_local) @allow_local = options.delete(:allow_local)
@full_path = !options.delete(:omit_query_string) @full_path = !options.delete(:omit_query_string)
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket) @options = {
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT) follow: {
max_hops: 3,
on_redirect: ->(response, request) { re_sign_on_redirect(response, request) },
},
socket_class: use_proxy? || @allow_local ? ProxySocket : Socket,
}.merge(options)
@options = @options.merge(proxy_url) if use_proxy? @options = @options.merge(proxy_url) if use_proxy?
@headers = {} @headers = {}
@signing = nil
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
set_common_headers! set_common_headers!
@ -92,8 +97,9 @@ class Request
def on_behalf_of(actor, sign_with: nil) def on_behalf_of(actor, sign_with: nil)
raise ArgumentError, 'actor must not be nil' if actor.nil? raise ArgumentError, 'actor must not be nil' if actor.nil?
@actor = actor key_id = ActivityPub::TagManager.instance.key_uri_for(actor)
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @actor.keypair keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair
@signing = HttpSignatureDraft.new(keypair, key_id, full_path: @full_path)
self self
end end
@ -119,7 +125,7 @@ class Request
end end
def headers def headers
(@actor ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET) (@signing ? @headers.merge('Signature' => signature) : @headers)
end end
class << self class << self
@ -134,14 +140,13 @@ class Request
end end
def http_client def http_client
HTTP.use(:auto_inflate).follow(max_hops: 3) HTTP.use(:auto_inflate)
end end
end end
private private
def set_common_headers! def set_common_headers!
@headers[REQUEST_TARGET] = request_target
@headers['User-Agent'] = Mastodon::Version.user_agent @headers['User-Agent'] = Mastodon::Version.user_agent
@headers['Host'] = @url.host @headers['Host'] = @url.host
@headers['Date'] = Time.now.utc.httpdate @headers['Date'] = Time.now.utc.httpdate
@ -152,31 +157,28 @@ class Request
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
end end
def request_target
if @url.query.nil? || !@full_path
"#{@verb} #{@url.path}"
else
"#{@verb} #{@url.path}?#{@url.query}"
end
end
def signature def signature
algorithm = 'rsa-sha256' @signing.sign(@headers.without('User-Agent', 'Accept-Encoding'), @verb, @url)
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end end
def signed_string def re_sign_on_redirect(_response, request)
signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") # Delete existing signature if there is one, since it will be invalid
request.headers.delete('Signature')
return unless @signing.present? && @verb == :get
signed_headers = request.headers.to_h.slice(*@headers.keys)
unless @headers.keys.all? { |key| signed_headers.key?(key) }
# We have lost some headers in the process, so don't sign the new
# request, in order to avoid issuing a valid signature with fewer
# conditions than expected.
Rails.logger.warn { "Some headers (#{@headers.keys - signed_headers.keys}) have been lost on redirect from {@uri} to #{request.uri}, this should not happen. Skipping signatures" }
return
end end
def signed_headers signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri))
@headers.without('User-Agent', 'Accept-Encoding') request.headers['Signature'] = signature_value
end
def key_id
ActivityPub::TagManager.instance.key_uri_for(@actor)
end end
def http_client def http_client

View File

@ -37,7 +37,8 @@ class Poll < ApplicationRecord
validates :options, presence: true validates :options, presence: true
validates :expires_at, presence: true, if: :local? validates :expires_at, presence: true, if: :local?
validates_with PollValidator, on: :create, if: :local? validates_with PollOptionsValidator, if: :local?
validates_with PollExpirationValidator, if: -> { local? && expires_at_changed? }
before_validation :prepare_options, if: :local? before_validation :prepare_options, if: :local?
before_validation :prepare_votes_count before_validation :prepare_votes_count

View File

@ -19,10 +19,10 @@ class InitialStateSerializer < ActiveModel::Serializer
def poll_limits def poll_limits
{ {
max_options: PollValidator::MAX_OPTIONS, max_options: PollOptionsValidator::MAX_OPTIONS,
max_option_chars: PollValidator::MAX_OPTION_CHARS, max_option_chars: PollOptionsValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION, min_expiration: PollExpirationValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION, max_expiration: PollExpirationValidator::MAX_EXPIRATION,
} }
end end

View File

@ -88,10 +88,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
}, },
polls: { polls: {
max_options: PollValidator::MAX_OPTIONS, max_options: PollOptionsValidator::MAX_OPTIONS,
max_characters_per_option: PollValidator::MAX_OPTION_CHARS, max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION, min_expiration: PollExpirationValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION, max_expiration: PollExpirationValidator::MAX_EXPIRATION,
}, },
translation: { translation: {

View File

@ -42,10 +42,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
def poll_limits def poll_limits
{ {
max_options: PollValidator::MAX_OPTIONS, max_options: PollOptionsValidator::MAX_OPTIONS,
max_option_chars: PollValidator::MAX_OPTION_CHARS, max_option_chars: PollOptionsValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION, min_expiration: PollExpirationValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION, max_expiration: PollExpirationValidator::MAX_EXPIRATION,
} }
end end
@ -84,10 +84,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
}, },
polls: { polls: {
max_options: PollValidator::MAX_OPTIONS, max_options: PollOptionsValidator::MAX_OPTIONS,
max_characters_per_option: PollValidator::MAX_OPTION_CHARS, max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION, min_expiration: PollExpirationValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION, max_expiration: PollExpirationValidator::MAX_EXPIRATION,
}, },
} }
end end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class PollExpirationValidator < ActiveModel::Validator
MAX_EXPIRATION = 1.month.freeze
MIN_EXPIRATION = 5.minutes.freeze
def validate(poll)
current_time = Time.now.utc
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION
end
end

View File

@ -1,19 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class PollValidator < ActiveModel::Validator class PollOptionsValidator < ActiveModel::Validator
MAX_OPTIONS = (ENV['MAX_POLL_OPTIONS'] || 5).to_i MAX_OPTIONS = (ENV['MAX_POLL_OPTIONS'] || 5).to_i
MAX_OPTION_CHARS = (ENV['MAX_POLL_OPTION_CHARS'] || 100).to_i MAX_OPTION_CHARS = (ENV['MAX_POLL_OPTION_CHARS'] || 100).to_i
MAX_EXPIRATION = 1.month.freeze
MIN_EXPIRATION = 5.minutes.freeze
def validate(poll) def validate(poll)
current_time = Time.now.utc
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS
poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS }
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION
end end
end end

View File

@ -1247,6 +1247,7 @@ cs:
too_fast: Formulář byl odeslán příliš rychle, zkuste to znovu. too_fast: Formulář byl odeslán příliš rychle, zkuste to znovu.
use_security_key: Použít bezpečnostní klíč use_security_key: Použít bezpečnostní klíč
user_agreement_html: Přečetl jsem si a souhlasím s <a href="%{terms_of_service_path}" target="_blank">podmínkami služby</a> a <a href="%{privacy_policy_path}" target="_blank">ochranou osobních údajů</a> user_agreement_html: Přečetl jsem si a souhlasím s <a href="%{terms_of_service_path}" target="_blank">podmínkami služby</a> a <a href="%{privacy_policy_path}" target="_blank">ochranou osobních údajů</a>
user_privacy_agreement_html: Četl jsem a souhlasím se zásadami <a href="%{privacy_policy_path}" target="_blank">ochrany osobních údajů</a>
author_attribution: author_attribution:
example_title: Ukázkový text example_title: Ukázkový text
hint_html: Píšete novinové články nebo blog mimo Mastodon? Kontrolujte, jak Vám bude připisováno autorství, když jsou sdíleny na Mastodonu. hint_html: Píšete novinové články nebo blog mimo Mastodon? Kontrolujte, jak Vám bude připisováno autorství, když jsou sdíleny na Mastodonu.

View File

@ -302,7 +302,7 @@ ko:
deleted_account: 계정을 삭제했습니다 deleted_account: 계정을 삭제했습니다
empty: 로그를 찾을 수 없습니다 empty: 로그를 찾을 수 없습니다
filter_by_action: 동작 별 필터 filter_by_action: 동작 별 필터
filter_by_user: 사용자로 거르기 filter_by_user: 사용자 기준으로 필터
title: 감사 로그 title: 감사 로그
unavailable_instance: "(도메인네임 사용불가)" unavailable_instance: "(도메인네임 사용불가)"
announcements: announcements:
@ -1192,6 +1192,7 @@ ko:
too_fast: 너무 빠르게 양식이 제출되었습니다, 다시 시도하세요. too_fast: 너무 빠르게 양식이 제출되었습니다, 다시 시도하세요.
use_security_key: 보안 키 사용 use_security_key: 보안 키 사용
user_agreement_html: <a href="%{terms_of_service_path}" target="_blank">이용 약관</a>과 <a href="%{privacy_policy_path}" target="_blank">개인정보처리방침</a>을 읽었고 동의합니다 user_agreement_html: <a href="%{terms_of_service_path}" target="_blank">이용 약관</a>과 <a href="%{privacy_policy_path}" target="_blank">개인정보처리방침</a>을 읽었고 동의합니다
user_privacy_agreement_html: <a href="%{privacy_policy_path}" target="_blank">개인정보처리방침</a>을 읽었고 동의합니다
author_attribution: author_attribution:
example_title: 예시 텍스트 example_title: 예시 텍스트
hint_html: 마스토돈 밖에서 뉴스나 블로그 글을 쓰시나요? 마스토돈에 공유되었을 때 어떻게 표시될지를 제어하세요. hint_html: 마스토돈 밖에서 뉴스나 블로그 글을 쓰시나요? 마스토돈에 공유되었을 때 어떻게 표시될지를 제어하세요.

View File

@ -3,6 +3,7 @@ cs:
simple_form: simple_form:
hints: hints:
account: account:
attribution_domains: Jeden na řádek. Chrání před falešným připisování autorství.
discoverable: Vaše veřejné příspěvky a profil mohou být zobrazeny nebo doporučeny v různých oblastech Mastodonu a váš profil může být navrhován ostatním uživatelům. discoverable: Vaše veřejné příspěvky a profil mohou být zobrazeny nebo doporučeny v různých oblastech Mastodonu a váš profil může být navrhován ostatním uživatelům.
display_name: Vaše celé jméno nebo přezdívka. display_name: Vaše celé jméno nebo přezdívka.
fields: Vaše domovská stránka, zájmena, věk, cokoliv chcete. fields: Vaše domovská stránka, zájmena, věk, cokoliv chcete.
@ -155,6 +156,7 @@ cs:
url: Kam budou události odesílány url: Kam budou události odesílány
labels: labels:
account: account:
attribution_domains: Webové stránky s povolením Vám připsat autorství
discoverable: Zobrazovat profil a příspěvky ve vyhledávacích algoritmech discoverable: Zobrazovat profil a příspěvky ve vyhledávacích algoritmech
fields: fields:
name: Označení name: Označení

View File

@ -23,7 +23,7 @@ module Devise
protected protected
def valid_params? def valid_params?
params[scope] && params[scope][:password].present? params[scope].is_a?(Hash) && params[scope][:password].present?
end end
end end
end end

View File

@ -22,7 +22,7 @@ module Devise
protected protected
def valid_params? def valid_params?
params[scope].respond_to?(:[]) && params[scope][:password].present? params[scope].is_a?(Hash) && params[scope][:password].present?
end end
end end
end end

View File

@ -48,15 +48,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } } post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
end end
it 'records a block' do it 'records a block, calls a worker, redirects' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'silence')).to be true expect(DomainBlock.exists?(domain: 'example.com', severity: 'silence')).to be true
end
it 'calls DomainBlockWorker' do
expect(DomainBlockWorker).to have_received(:perform_async) expect(DomainBlockWorker).to have_received(:perform_async)
end
it 'redirects with a success message' do
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg') expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
expect(response).to redirect_to(admin_instances_path(limited: '1')) expect(response).to redirect_to(admin_instances_path(limited: '1'))
end end
@ -68,15 +64,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } } post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
end end
it 'does not record a block' do it 'does not record a block or call worker, renders new' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'silence')).to be false expect(DomainBlock.exists?(domain: 'example.com', severity: 'silence')).to be false
end
it 'does not call DomainBlockWorker' do
expect(DomainBlockWorker).to_not have_received(:perform_async) expect(DomainBlockWorker).to_not have_received(:perform_async)
end
it 'renders new' do
expect(response).to render_template :new expect(response).to render_template :new
end end
end end
@ -87,15 +79,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { domain_block: { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true } } post :create, params: { domain_block: { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true } }
end end
it 'does not record a block' do it 'does not record a block or call worker, renders confirm suspension' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be false expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be false
end
it 'does not call DomainBlockWorker' do
expect(DomainBlockWorker).to_not have_received(:perform_async) expect(DomainBlockWorker).to_not have_received(:perform_async)
end
it 'renders confirm_suspension' do
expect(response).to render_template :confirm_suspension expect(response).to render_template :confirm_suspension
end end
end end
@ -105,15 +93,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { :domain_block => { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true }, 'confirm' => '' } post :create, params: { :domain_block => { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true }, 'confirm' => '' }
end end
it 'records a block' do it 'records a block and calls worker and redirects' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be true expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be true
end
it 'calls DomainBlockWorker' do
expect(DomainBlockWorker).to have_received(:perform_async) expect(DomainBlockWorker).to have_received(:perform_async)
end
it 'redirects with a success message' do
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg') expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
expect(response).to redirect_to(admin_instances_path(limited: '1')) expect(response).to redirect_to(admin_instances_path(limited: '1'))
end end
@ -130,15 +114,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { domain_block: { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true } } post :create, params: { domain_block: { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true } }
end end
it 'does not record a block' do it 'does not record a block or call worker, renders confirm suspension' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be false expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be false
end
it 'does not call DomainBlockWorker' do
expect(DomainBlockWorker).to_not have_received(:perform_async) expect(DomainBlockWorker).to_not have_received(:perform_async)
end
it 'renders confirm_suspension' do
expect(response).to render_template :confirm_suspension expect(response).to render_template :confirm_suspension
end end
end end
@ -148,15 +128,11 @@ RSpec.describe Admin::DomainBlocksController do
post :create, params: { :domain_block => { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true }, 'confirm' => '' } post :create, params: { :domain_block => { domain: 'example.com', severity: 'suspend', reject_media: true, reject_reports: true }, 'confirm' => '' }
end end
it 'updates the record' do it 'updates the record and calls worker, redirects' do
expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be true expect(DomainBlock.exists?(domain: 'example.com', severity: 'suspend')).to be true
end
it 'calls DomainBlockWorker' do
expect(DomainBlockWorker).to have_received(:perform_async) expect(DomainBlockWorker).to have_received(:perform_async)
end
it 'redirects with a success message' do
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg') expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
expect(response).to redirect_to(admin_instances_path(limited: '1')) expect(response).to redirect_to(admin_instances_path(limited: '1'))
end end

View File

@ -58,11 +58,9 @@ RSpec.describe Admin::EmailDomainBlocksController do
post :create, params: { email_domain_block: { domain: 'example.com' }, save: '' } post :create, params: { email_domain_block: { domain: 'example.com' }, save: '' }
end end
it 'blocks the domain' do it 'blocks the domain and redirects to email domain blocks' do
expect(EmailDomainBlock.find_by(domain: 'example.com')).to_not be_nil expect(EmailDomainBlock.find_by(domain: 'example.com')).to_not be_nil
end
it 'redirects to e-mail domain blocks' do
expect(response).to redirect_to(admin_email_domain_blocks_path) expect(response).to redirect_to(admin_email_domain_blocks_path)
end end
end end

View File

@ -68,11 +68,9 @@ RSpec.describe Admin::RolesController do
let(:selected_position) { 1 } let(:selected_position) { 1 }
let(:selected_permissions_as_keys) { %w(manage_roles) } let(:selected_permissions_as_keys) { %w(manage_roles) }
it 'redirects to roles page' do it 'redirects to roles page and creates role' do
expect(response).to redirect_to(admin_roles_path) expect(response).to redirect_to(admin_roles_path)
end
it 'creates new role' do
expect(UserRole.find_by(name: 'Bar')).to_not be_nil expect(UserRole.find_by(name: 'Bar')).to_not be_nil
end end
end end
@ -81,11 +79,9 @@ RSpec.describe Admin::RolesController do
let(:selected_position) { 100 } let(:selected_position) { 100 }
let(:selected_permissions_as_keys) { %w(manage_roles) } let(:selected_permissions_as_keys) { %w(manage_roles) }
it 'renders new template' do it 'renders new template and does not create role' do
expect(response).to render_template(:new) expect(response).to render_template(:new)
end
it 'does not create new role' do
expect(UserRole.find_by(name: 'Bar')).to be_nil expect(UserRole.find_by(name: 'Bar')).to be_nil
end end
end end
@ -94,11 +90,9 @@ RSpec.describe Admin::RolesController do
let(:selected_position) { 1 } let(:selected_position) { 1 }
let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) } let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) }
it 'renders new template' do it 'renders new template and does not create role' do
expect(response).to render_template(:new) expect(response).to render_template(:new)
end
it 'does not create new role' do
expect(UserRole.find_by(name: 'Bar')).to be_nil expect(UserRole.find_by(name: 'Bar')).to be_nil
end end
end end
@ -109,11 +103,9 @@ RSpec.describe Admin::RolesController do
let(:selected_position) { 1 } let(:selected_position) { 1 }
let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) } let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) }
it 'redirects to roles page' do it 'redirects to roles page and creates new role' do
expect(response).to redirect_to(admin_roles_path) expect(response).to redirect_to(admin_roles_path)
end
it 'creates new role' do
expect(UserRole.find_by(name: 'Bar')).to_not be_nil expect(UserRole.find_by(name: 'Bar')).to_not be_nil
end end
end end
@ -166,11 +158,9 @@ RSpec.describe Admin::RolesController do
end end
context 'when user does not have permission to manage roles' do context 'when user does not have permission to manage roles' do
it 'returns http forbidden' do it 'returns http forbidden and does not update role' do
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end
it 'does not update the role' do
expect(role.reload.name).to eq 'Bar' expect(role.reload.name).to eq 'Bar'
end end
end end
@ -179,11 +169,9 @@ RSpec.describe Admin::RolesController do
let(:permissions) { UserRole::FLAGS[:manage_roles] } let(:permissions) { UserRole::FLAGS[:manage_roles] }
context 'when role has permissions the user doesn\'t' do context 'when role has permissions the user doesn\'t' do
it 'renders edit template' do it 'renders edit template and does not update role' do
expect(response).to render_template(:edit) expect(response).to render_template(:edit)
end
it 'does not update the role' do
expect(role.reload.name).to eq 'Bar' expect(role.reload.name).to eq 'Bar'
end end
end end
@ -192,11 +180,9 @@ RSpec.describe Admin::RolesController do
let(:permissions) { UserRole::FLAGS[:manage_roles] | UserRole::FLAGS[:manage_users] } let(:permissions) { UserRole::FLAGS[:manage_roles] | UserRole::FLAGS[:manage_users] }
context 'when user outranks the role' do context 'when user outranks the role' do
it 'redirects to roles page' do it 'redirects to roles page and updates role' do
expect(response).to redirect_to(admin_roles_path) expect(response).to redirect_to(admin_roles_path)
end
it 'updates the role' do
expect(role.reload.name).to eq 'Baz' expect(role.reload.name).to eq 'Baz'
end end
end end
@ -204,11 +190,9 @@ RSpec.describe Admin::RolesController do
context 'when role outranks user' do context 'when role outranks user' do
let(:role_position) { current_role.position + 1 } let(:role_position) { current_role.position + 1 }
it 'returns http forbidden' do it 'returns http forbidden and does not update role' do
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end
it 'does not update the role' do
expect(role.reload.name).to eq 'Bar' expect(role.reload.name).to eq 'Bar'
end end
end end

View File

@ -44,11 +44,9 @@ RSpec.describe Admin::Users::RolesController do
let(:permissions) { UserRole::FLAGS[:manage_roles] } let(:permissions) { UserRole::FLAGS[:manage_roles] }
let(:position) { 1 } let(:position) { 1 }
it 'updates user role' do it 'updates user role and redirects' do
expect(user.reload.role_id).to eq selected_role&.id expect(user.reload.role_id).to eq selected_role&.id
end
it 'redirects back to account page' do
expect(response).to redirect_to(admin_account_path(user.account_id)) expect(response).to redirect_to(admin_account_path(user.account_id))
end end
end end
@ -57,11 +55,9 @@ RSpec.describe Admin::Users::RolesController do
let(:permissions) { UserRole::FLAGS[:administrator] } let(:permissions) { UserRole::FLAGS[:administrator] }
let(:position) { 100 } let(:position) { 100 }
it 'does not update user role' do it 'does not update user role and renders edit' do
expect(user.reload.role_id).to eq previous_role&.id expect(user.reload.role_id).to eq previous_role&.id
end
it 'renders edit form' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
end end
end end
@ -71,11 +67,9 @@ RSpec.describe Admin::Users::RolesController do
let(:permissions) { UserRole::FLAGS[:manage_roles] } let(:permissions) { UserRole::FLAGS[:manage_roles] }
let(:position) { 1 } let(:position) { 1 }
it 'does not update user role' do it 'does not update user role and returns http forbidden' do
expect(user.reload.role_id).to eq previous_role&.id expect(user.reload.role_id).to eq previous_role&.id
end
it 'returns http forbidden' do
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
end end

View File

@ -60,16 +60,12 @@ RSpec.describe Request do
expect(a_request(:get, 'http://example.com')).to have_been_made.once expect(a_request(:get, 'http://example.com')).to have_been_made.once
end end
it 'sets headers' do it 'makes a request with expected headers, yields, and closes the underlying connection' do
expect { |block| subject.perform(&block) }.to yield_control
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
end
it 'closes underlying connection' do
allow(subject.send(:http_client)).to receive(:close) allow(subject.send(:http_client)).to receive(:close)
expect { |block| subject.perform(&block) }.to yield_control expect { |block| subject.perform(&block) }.to yield_control
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
expect(subject.send(:http_client)).to have_received(:close) expect(subject.send(:http_client)).to have_received(:close)
end end
@ -80,6 +76,29 @@ RSpec.describe Request do
end end
end end
context 'with a redirect and HTTP signatures' do
let(:account) { Fabricate(:account) }
before do
stub_request(:get, 'http://example.com').to_return(status: 301, headers: { Location: 'http://redirected.example.com/foo' })
stub_request(:get, 'http://redirected.example.com/foo').to_return(body: 'lorem ipsum')
end
it 'makes a request with expected headers and follows redirects' do
expect { |block| subject.on_behalf_of(account).perform(&block) }.to yield_control
# request.headers includes the `Signature` sent for the first request
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made.once
# request.headers includes the `Signature`, but it has changed
expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.merge({ 'Host' => 'redirected.example.com' }))).to_not have_been_made
# `with(headers: )` matching tests for inclusion, so strip `Signature`
# This doesn't actually test that there is a signature, but it tests that the original signature is not passed
expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.without('Signature').merge({ 'Host' => 'redirected.example.com' }))).to have_been_made.once
end
end
context 'with private host' do context 'with private host' do
around do |example| around do |example|
WebMock.disable! WebMock.disable!

View File

@ -59,7 +59,7 @@ RSpec.describe 'Instances' do
description_limit: MediaAttachment::MAX_DESCRIPTION_LENGTH description_limit: MediaAttachment::MAX_DESCRIPTION_LENGTH
), ),
polls: include( polls: include(
max_options: PollValidator::MAX_OPTIONS max_options: PollOptionsValidator::MAX_OPTIONS
) )
) )
) )

View File

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PollValidator do RSpec.describe PollExpirationValidator do
describe '#validate' do describe '#validate' do
before do before do
validator.validate(poll) validator.validate(poll)
@ -14,16 +14,24 @@ RSpec.describe PollValidator do
let(:options) { %w(foo bar) } let(:options) { %w(foo bar) }
let(:expires_at) { 1.day.from_now } let(:expires_at) { 1.day.from_now }
it 'have no errors' do it 'has no errors' do
expect(errors).to_not have_received(:add) expect(errors).to_not have_received(:add)
end end
context 'when expires is just 5 min ago' do context 'when the poll expires in 5 min from now' do
let(:expires_at) { 5.minutes.from_now } let(:expires_at) { 5.minutes.from_now }
it 'not calls errors add' do it 'has no errors' do
expect(errors).to_not have_received(:add) expect(errors).to_not have_received(:add)
end end
end end
context 'when the poll expires in the past' do
let(:expires_at) { 5.minutes.ago }
it 'has errors' do
expect(errors).to have_received(:add)
end
end
end end
end end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PollOptionsValidator do
describe '#validate' do
before do
validator.validate(poll)
end
let(:validator) { described_class.new }
let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) }
let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:options) { %w(foo bar) }
let(:expires_at) { 1.day.from_now }
it 'has no errors' do
expect(errors).to_not have_received(:add)
end
context 'when the poll has duplicate options' do
let(:options) { %w(foo foo) }
it 'adds errors' do
expect(errors).to have_received(:add)
end
end
context 'when the poll has no options' do
let(:options) { [] }
it 'adds errors' do
expect(errors).to have_received(:add)
end
end
context 'when the poll has too many options' do
let(:options) { Array.new(described_class::MAX_OPTIONS + 1) { |i| "option #{i}" } }
it 'adds errors' do
expect(errors).to have_received(:add)
end
end
end
end