Merge branch 'main' into feature/require-mfa-by-admin

This commit is contained in:
FredysFonseca 2025-07-29 18:13:55 -04:00 committed by GitHub
commit 63d0efa0c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 672 additions and 121 deletions

View File

@ -96,7 +96,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1131.0)
aws-partitions (1.1135.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -233,7 +233,7 @@ GEM
fabrication (3.0.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.2)
faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@ -345,7 +345,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.13.0)
json (2.13.2)
json-canonicalization (1.0.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
@ -438,7 +438,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0715)
mime-types-data (3.2025.0722)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
@ -601,13 +601,13 @@ GEM
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.9)
pg (1.6.0)
pghero (3.7.0)
activerecord (>= 7.1)
playwright-ruby-client (1.54.0)
@ -731,7 +731,7 @@ GEM
railties (>= 5.2)
rexml (3.4.1)
rotp (6.3.0)
rouge (4.5.2)
rouge (4.6.0)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
@ -868,7 +868,7 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
temple (0.10.3)
temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
@ -1108,4 +1108,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.7.0
2.7.1

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class Admin::UsernameBlocksController < Admin::BaseController
before_action :set_username_block, only: [:edit, :update]
def index
authorize :username_block, :index?
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
@form = Form::UsernameBlockBatch.new
end
def batch
authorize :username_block, :index?
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
ensure
redirect_to admin_username_blocks_path
end
def new
authorize :username_block, :create?
@username_block = UsernameBlock.new(exact: true)
end
def edit
authorize @username_block, :update?
end
def create
authorize :username_block, :create?
@username_block = UsernameBlock.new(resource_params)
if @username_block.save
log_action :create, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
else
render :new
end
end
def update
authorize @username_block, :update?
if @username_block.update(resource_params)
log_action :update, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
else
render :new
end
end
private
def set_username_block
@username_block = UsernameBlock.find(params[:id])
end
def form_username_block_batch_params
params
.expect(form_username_block_batch: [username_block_ids: []])
end
def resource_params
params
.expect(username_block: [:username, :comparison, :allow_with_approval])
end
def action_from_button
'delete' if params[:delete]
end
end

View File

@ -66,7 +66,11 @@ class Api::V1::StatusesController < Api::BaseController
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
end
end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)

View File

@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end
when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id)
when 'UsernameBlock'
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'

View File

@ -2,8 +2,6 @@ import { useEffect, useState, useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import {
fetchContext,
completeContextRefresh,
@ -22,8 +20,7 @@ const messages = defineMessages({
export const RefreshController: React.FC<{
statusId: string;
withBorder?: boolean;
}> = ({ statusId, withBorder }) => {
}> = ({ statusId }) => {
const refresh = useAppSelector(
(state) => state.contexts.refreshing[statusId],
);
@ -78,12 +75,7 @@ export const RefreshController: React.FC<{
if (ready && !loading) {
return (
<button
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
onClick={handleClick}
>
<button className='load-more load-gap' onClick={handleClick}>
<FormattedMessage
id='status.context.load_new_replies'
defaultMessage='New replies available'
@ -98,9 +90,7 @@ export const RefreshController: React.FC<{
return (
<div
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
className='load-more load-gap'
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)}

View File

@ -580,7 +580,6 @@ class Status extends ImmutablePureComponent {
remoteHint = (
<RefreshController
statusId={status.get('id')}
withBorder={!!descendants}
/>
);
}
@ -653,8 +652,8 @@ class Status extends ImmutablePureComponent {
</div>
</Hotkeys>
{descendants}
{remoteHint}
{descendants}
</div>
</ScrollContainer>

View File

@ -235,7 +235,7 @@
"confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
"confirmations.logout.title": "خروج؟",
"confirmations.missing_alt_text.confirm": "متن جایگزین را اضافه کنید",
"confirmations.missing_alt_text.message": "پست شما حاوی رسانه بدون متن جایگزین است. افزودن توضیحات کمک می کند تا محتوای شما برای افراد بیشتری قابل دسترسی باشد.",
"confirmations.missing_alt_text.message": "فرسته‌تان رسانه‌هایی بدون متن جایگزین دارد. افزودن شرح به دسترس‌پذیر شدن محتوایتان برای افراد بیش‌تری کمک می‌کند.",
"confirmations.missing_alt_text.secondary": "به هر حال پست کن",
"confirmations.missing_alt_text.title": "متن جایگزین اضافه شود؟",
"confirmations.mute.confirm": "خموش",
@ -424,7 +424,7 @@
"hints.profiles.see_more_followers": "دیدن پی‌گیرندگان بیش‌تر روی {domain}",
"hints.profiles.see_more_follows": "دیدن پی‌گرفته‌های بیش‌تر روی {domain}",
"hints.profiles.see_more_posts": "دیدن فرسته‌های بیش‌تر روی {domain}",
"home.column_settings.show_quotes": "نمایش نقل‌قول‌ها",
"home.column_settings.show_quotes": "نمایش نقل‌ها",
"home.column_settings.show_reblogs": "نمایش تقویت‌ها",
"home.column_settings.show_replies": "نمایش پاسخ‌ها",
"home.hide_announcements": "نهفتن اعلامیه‌ها",
@ -845,6 +845,8 @@
"status.bookmark": "نشانک",
"status.cancel_reblog_private": "ناتقویت",
"status.cannot_reblog": "این فرسته قابل تقویت نیست",
"status.context.load_new_replies": "پاسخ‌های جدیدی موجودند",
"status.context.loading": "بررسی کردن برای پاسخ‌های بیش‌تر",
"status.continued_thread": "رشتهٔ دنباله دار",
"status.copy": "رونوشت از پیوند فرسته",
"status.delete": "حذف",
@ -873,7 +875,7 @@
"status.quote_error.filtered": "نهفته بنا بر یکی از پالایه‌هایتان",
"status.quote_error.not_found": "این فرسته قابل نمایش نیست.",
"status.quote_error.pending_approval": "این فرسته منظر تأیید نگارندهٔ اصلی است.",
"status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی فرسته اجازهٔ نقل قولش را نمی‌دهد این فرسته قابل نمایش نیست.",
"status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی این فرسته اجازهٔ نقلش را نمی‌دهد قابل نمایش نیست.",
"status.quote_error.removed": "این فرسته به دست نگارنده‌اش برداشته شده.",
"status.quote_error.unauthorized": "از آن‌جا که اجازهٔ دیدن این فرسته را ندارید قابل نمایش نیست.",
"status.quote_post_author": "فرسته توسط {name}",

View File

@ -224,6 +224,8 @@
"confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas ao post sendo editado.",
"confirmations.discard_draft.edit.title": "Descartar mudanças no seu post?",
"confirmations.discard_draft.post.cancel": "Continuar rascunho",
"confirmations.discard_draft.post.message": "Continuar eliminará a publicação que está sendo elaborada no momento.",
"confirmations.discard_draft.post.title": "Eliminar seu esboço de publicação?",
"confirmations.discard_edit_media.confirm": "Descartar",
"confirmations.discard_edit_media.message": "Há mudanças não salvas na descrição ou pré-visualização da mídia. Descartar assim mesmo?",
"confirmations.follow_to_list.confirm": "Seguir e adicionar à lista",
@ -333,9 +335,13 @@
"errors.unexpected_crash.copy_stacktrace": "Copiar dados do erro para área de transferência",
"errors.unexpected_crash.report_issue": "Reportar problema",
"explore.suggested_follows": "Pessoas",
"explore.title": "Em alta",
"explore.trending_links": "Notícias",
"explore.trending_statuses": "Publicações",
"explore.trending_tags": "Hashtags",
"featured_carousel.header": "{count, plural, one {Postagem fixada} other {Postagens fixadas}}",
"featured_carousel.next": "Próximo",
"featured_carousel.previous": "Anterior",
"filter_modal.added.context_mismatch_explanation": "Esta categoria de filtro não se aplica ao contexto no qual você acessou esta publicação. Se quiser que a publicação seja filtrada nesse contexto também, você terá que editar o filtro.",
"filter_modal.added.context_mismatch_title": "Incompatibilidade de contexto!",
"filter_modal.added.expired_explanation": "Esta categoria de filtro expirou, você precisará alterar a data de expiração para aplicar.",
@ -550,6 +556,7 @@
"navigation_bar.lists": "Listas",
"navigation_bar.logout": "Sair",
"navigation_bar.moderation": "Moderação",
"navigation_bar.more": "Mais",
"navigation_bar.mutes": "Usuários silenciados",
"navigation_bar.opened_in_classic_interface": "Publicações, contas e outras páginas específicas são abertas por padrão na interface 'web' clássica.",
"navigation_bar.preferences": "Preferências",

View File

@ -845,6 +845,8 @@
"status.bookmark": "Bokmärk",
"status.cancel_reblog_private": "Sluta boosta",
"status.cannot_reblog": "Detta inlägg kan inte boostas",
"status.context.load_new_replies": "Nya svar finns",
"status.context.loading": "Letar efter fler svar",
"status.continued_thread": "Fortsatt tråd",
"status.copy": "Kopiera inläggslänk",
"status.delete": "Radera",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM477-161q45 0 86-11.5t77-33.5l-91-91q-38 17-74.5 47.5T412-168q16 3 32 5t33 2Zm-124-25q35-72 79.5-107t67.5-47q-29-9-58.5-14.5T380-360q-45 0-89 11t-85 31q26 43 63.5 77.5T353-186Zm461-74L690-384q31-10 50.5-36t19.5-60q0-42-29-71t-71-29q-34 0-60 19.5T564-510l-44-44q2-61-41-104.5T374-700L260-814q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM380-420q11 0 20.5-1.5T420-426L246-600q-3 10-4.5 19.5T240-560q0 58 41 99t99 41Z"/></svg>

After

Width:  |  Height:  |  Size: 698 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM412-168q26-51 62-81.5t75-47.5L204-642q-21 36-32.5 76.5T160-480q0 45 11.5 86t34.5 76q41-20 85-31t89-11q32 0 61.5 5.5T500-340q-23 12-43.5 28T418-278q-12-2-20.5-2H380q-32 0-63.5 7T256-252q32 32 71.5 53.5T412-168Zm402-92-58-58q21-35 32.5-76t11.5-86q0-134-93-227t-227-93q-45 0-85.5 11.5T318-756l-58-58q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM520-554 374-700q62-2 105 41.5T520-554ZM380-420q-58 0-99-41t-41-99q0-33 14.5-60.5T292-668l196 196q-20 23-47.5 37.5T380-420Zm310 36L564-510q10-31 36-50.5t60-19.5q42 0 71 29t29 71q0 34-19.5 60T690-384ZM537-537ZM423-423Z"/></svg>

After

Width:  |  Height:  |  Size: 843 B

View File

@ -33,9 +33,7 @@ class Antispam
end
def local_preflight_check!(status)
return unless spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
return unless suspicious_reply_or_mention?(status)
return unless status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago
return unless considered_spam?(status)
report_if_needed!(status.account)
@ -44,10 +42,26 @@ class Antispam
private
def considered_spam?(status)
(all_time_suspicious?(status) || recent_suspicious?(status)) && suspicious_reply_or_mention?(status)
end
def all_time_suspicious?(status)
all_time_spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
end
def recent_suspicious?(status)
status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago && spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) }
end
def spammy_texts
redis.smembers('antispam:spammy_texts')
end
def all_time_spammy_texts
redis.smembers('antispam:all_time_spammy_texts')
end
def suspicious_reply_or_mention?(status)
parent = status.thread
return true if parent.present? && !Follow.exists?(account_id: parent.account_id, target_account: status.account_id)

View File

@ -77,6 +77,9 @@ class Admin::ActionLogFilter
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
create_username_block: { target_type: 'UsernameBlock', action: 'create' }.freeze,
update_username_block: { target_type: 'UsernameBlock', action: 'update' }.freeze,
destroy_username_block: { target_type: 'UsernameBlock', action: 'destroy' }.freeze,
}.freeze
attr_reader :params

View File

@ -33,7 +33,7 @@ module Status::FetchRepliesConcern
def should_fetch_replies?
# we aren't brand new, and we haven't fetched replies since the debounce window
!local? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
!local? && distributable? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
fetched_replies_at.nil? || fetched_replies_at <= FETCH_REPLIES_COOLDOWN_MINUTES.ago
)
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Form::UsernameBlockBatch < Form::BaseBatch
attr_accessor :username_block_ids
def save
case action
when 'delete'
delete!
end
end
private
def username_blocks
@username_blocks ||= UsernameBlock.where(id: username_block_ids)
end
def delete!
verify_authorization(:destroy?)
username_blocks.each do |username_block|
username_block.destroy
log_action :destroy, username_block
end
end
def verify_authorization(permission)
username_blocks.each { |username_block| authorize(username_block, permission) }
end
end

View File

@ -443,7 +443,7 @@ class User < ApplicationRecord
def set_approved
self.approved = begin
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval?
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval? || sign_up_username_requires_approval?
false
else
open_registrations? || valid_invitation? || external?
@ -499,6 +499,10 @@ class User < ApplicationRecord
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
end
def sign_up_username_requires_approval?
account.username? && UsernameBlock.matches?(account.username, allow_with_approval: true)
end
def open_registrations?
Setting.registrations_mode == 'open'
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: username_blocks
#
# id :bigint(8) not null, primary key
# allow_with_approval :boolean default(FALSE), not null
# exact :boolean default(FALSE), not null
# normalized_username :string not null
# username :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class UsernameBlock < ApplicationRecord
HOMOGLYPHS = {
'1' => 'i',
'2' => 'z',
'3' => 'e',
'4' => 'a',
'5' => 's',
'7' => 't',
'8' => 'b',
'9' => 'g',
'0' => 'o',
}.freeze
validates :username, presence: true, uniqueness: true
scope :matches_exactly, ->(str) { where(exact: true).where(normalized_username: str) }
scope :matches_partially, ->(str) { where(exact: false).where(Arel::Nodes.build_quoted(str).matches(Arel::Nodes.build_quoted('%').concat(arel_table[:normalized_username]).concat(Arel::Nodes.build_quoted('%')))) }
before_save :set_normalized_username
def comparison
exact? ? 'equals' : 'contains'
end
def comparison=(val)
self.exact = val == 'equals'
end
def self.matches?(str, allow_with_approval: false)
normalized_str = str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
where(allow_with_approval: allow_with_approval).matches_exactly(normalized_str).or(matches_partially(normalized_str)).any?
end
def to_log_human_identifier
username
end
private
def set_normalized_username
self.normalized_username = normalize(username)
end
def normalize(str)
str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
end
end

View File

@ -24,7 +24,7 @@ class WorkerBatch
begin
Thread.current[:batch] = self
yield
yield(self)
ensure
Thread.current[:batch] = nil
end
@ -33,10 +33,7 @@ class WorkerBatch
# Add jobs to the batch. Usually when the batch is created.
# @param [Array<String>] jids
def add_jobs(jids)
if jids.blank?
finish!
return
end
return if jids.empty?
redis.multi do |pipeline|
pipeline.sadd(key('jobs'), jids)
@ -48,7 +45,7 @@ class WorkerBatch
# Remove a job from the batch, such as when it's been processed or it has failed.
# @param [String] jid
def remove_job(jid)
def remove_job(jid, increment: false)
_, pending, processed, async_refresh_key, threshold = redis.multi do |pipeline|
pipeline.srem(key('jobs'), jid)
pipeline.hincrby(key, 'pending', -1)
@ -57,10 +54,8 @@ class WorkerBatch
pipeline.hget(key, 'threshold')
end
if async_refresh_key.present?
async_refresh = AsyncRefresh.new(async_refresh_key)
async_refresh.increment_result_count(by: 1)
end
async_refresh = AsyncRefresh.new(async_refresh_key) if async_refresh_key.present?
async_refresh&.increment_result_count(by: 1) if increment
if pending.zero? || processed >= (threshold || 1.0).to_f * (processed + pending)
async_refresh&.finish!

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class UsernameBlockPolicy < ApplicationPolicy
def index?
role.can?(:manage_blocks)
end
def create?
role.can?(:manage_blocks)
end
def update?
role.can?(:manage_blocks)
end
def destroy?
role.can?(:manage_blocks)
end
end

View File

@ -6,7 +6,7 @@ class ActivityPub::FetchAllRepliesService < ActivityPub::FetchRepliesService
# Limit of replies to fetch per status
MAX_REPLIES = (ENV['FETCH_REPLIES_MAX_SINGLE'] || 500).to_i
def call(status_uri, collection_or_uri, max_pages: 1, async_refresh_key: nil, request_id: nil)
def call(status_uri, collection_or_uri, max_pages: 1, batch_id: nil, request_id: nil)
@status_uri = status_uri
super

View File

@ -6,7 +6,7 @@ class ActivityPub::FetchRepliesService < BaseService
# Limit of fetched replies
MAX_REPLIES = 5
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, async_refresh_key: nil, request_id: nil)
def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, batch_id: nil, request_id: nil)
@reference_uri = reference_uri
@allow_synchronous_requests = allow_synchronous_requests
@ -15,10 +15,7 @@ class ActivityPub::FetchRepliesService < BaseService
@items = filter_replies(@items)
batch = WorkerBatch.new
batch.connect(async_refresh_key) if async_refresh_key.present?
batch.finish! if @items.empty?
batch.within do
WorkerBatch.new(batch_id).within do |batch|
FetchReplyWorker.push_bulk(@items) do |reply_uri|
[reply_uri, { 'request_id' => request_id, 'batch_id' => batch.id }]
end

View File

@ -28,14 +28,6 @@ class UnreservedUsernameValidator < ActiveModel::Validator
end
def settings_username_reserved?
settings_has_reserved_usernames? && settings_reserves_username?
end
def settings_has_reserved_usernames?
Setting.reserved_usernames.present?
end
def settings_reserves_username?
Setting.reserved_usernames.include?(@username.downcase)
UsernameBlock.matches?(@username, allow_with_approval: false)
end
end

View File

@ -0,0 +1,16 @@
.fields-group
= form.input :username,
wrapper: :with_block_label,
input_html: { autocomplete: 'new-password', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }
.fields-group
= form.input :comparison,
as: :select,
wrapper: :with_block_label,
collection: %w(equals contains),
include_blank: false,
label_method: ->(type) { I18n.t(type, scope: 'admin.username_blocks.comparison') }
.fields-group
= form.input :allow_with_approval,
wrapper: :with_label

View File

@ -0,0 +1,12 @@
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :username_block_ids, { multiple: true, include_hidden: false }, username_block.id
.sr-only= username_block.username
.batch-table__row__content.pending-account
.pending-account__header
= t(username_block.exact? ? 'admin.username_blocks.matches_exactly_html' : 'admin.username_blocks.contains_html', string: content_tag(:samp, link_to(username_block.username, edit_admin_username_block_path(username_block))))
%br/
- if username_block.allow_with_approval?
= t('admin.email_domain_blocks.allow_registrations_with_approval')
- else
= t('admin.username_blocks.block_registrations')

View File

@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.edit.title')
= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('generic.save_changes'), type: :submit

View File

@ -0,0 +1,26 @@
- content_for :page_title do
= t('admin.username_blocks.title')
- content_for :heading_actions do
= link_to t('admin.username_blocks.add_new'), new_admin_username_block_path, class: 'button'
= form_with model: @form, url: batch_admin_username_blocks_path do |f|
= hidden_field_tag :page, params[:page] || 1
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([material_symbol('close'), t('admin.username_blocks.delete')]),
class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') },
name: :delete,
type: :submit
.batch-table__body
- if @username_blocks.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'username_block', collection: @username_blocks, locals: { f: f }
= paginate @username_blocks

View File

@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.new.title')
= simple_form_for @username_block, url: admin_username_blocks_path do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('admin.username_blocks.new.create'), type: :submit

View File

@ -16,7 +16,9 @@ class ActivityPub::FetchAllRepliesWorker
MAX_PAGES = (ENV['FETCH_REPLIES_MAX_PAGES'] || 500).to_i
def perform(root_status_id, options = {})
@batch = WorkerBatch.new(options['batch_id'])
@root_status = Status.remote.find_by(id: root_status_id)
return unless @root_status&.should_fetch_replies?
@root_status.touch(:fetched_replies_at)
@ -45,6 +47,8 @@ class ActivityPub::FetchAllRepliesWorker
# Workers shouldn't be returning anything, but this is used in tests
fetched_uris
ensure
@batch.remove_job(jid)
end
private
@ -53,9 +57,10 @@ class ActivityPub::FetchAllRepliesWorker
# status URI, or the prefetched body of the Note object
def get_replies(status, max_pages, options = {})
replies_collection_or_uri = get_replies_uri(status)
return if replies_collection_or_uri.nil?
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, async_refresh_key: "context:#{@root_status.id}:refresh", **options.deep_symbolize_keys)
ActivityPub::FetchAllRepliesService.new.call(value_or_id(status), replies_collection_or_uri, max_pages: max_pages, **options.deep_symbolize_keys)
end
# Get the URI of the replies collection of a status
@ -78,9 +83,12 @@ class ActivityPub::FetchAllRepliesWorker
# @param root_status_uri [String]
def get_root_replies(root_status_uri, options = {})
root_status_body = fetch_resource(root_status_uri, true)
return if root_status_body.nil?
@batch.within do
FetchReplyWorker.perform_async(root_status_uri, { **options.deep_stringify_keys, 'prefetched_body' => root_status_body })
end
get_replies(root_status_body, MAX_PAGES, options)
end

View File

@ -8,8 +8,8 @@ class FetchReplyWorker
def perform(child_url, options = {})
batch = WorkerBatch.new(options.delete('batch_id')) if options['batch_id']
FetchRemoteStatusService.new.call(child_url, **options.symbolize_keys)
result = FetchRemoteStatusService.new.call(child_url, **options.symbolize_keys)
ensure
batch&.remove_job(jid)
batch&.remove_job(jid, increment: result.present?)
end
end

View File

@ -115,8 +115,10 @@ class MoveWorker
def carry_mutes_over!
@source_account.muted_by_relationships.where(account: Account.local).find_each do |mute|
MuteService.new.call(mute.account, @target_account, notifications: mute.hide_notifications) unless skip_mute_move?(mute)
unless skip_mute_move?(mute)
MuteService.new.call(mute.account, @target_account, notifications: mute.hide_notifications)
add_account_note_if_needed!(mute.account, 'move_handler.carry_mutes_over_text')
end
rescue => e
@deferred_error = e
end

View File

@ -72,6 +72,8 @@ ignore_unused:
- 'preferences.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'edit_profile.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'admin.terms_of_service.generate' # temporarily disabled
- 'admin.username_blocks.matches_exactly_html'
- 'admin.username_blocks.contains_html'
ignore_inconsistent_interpolations:
- '*.one'

View File

@ -53,7 +53,7 @@ ru:
subtitle: Двухфакторная аутентификация отключена для вашей учетной записи.
title: 2FA отключена
two_factor_enabled:
explanation: Для входа потребуется одноразовый код, сгенерированный сопряжённым приложением TOTP.
explanation: Для входа потребуется одноразовый код, сгенерированный сопряжённым приложением-аутентификатором.
subject: 'Mastodon: Двухфакторная аутентификация включена'
subtitle: Двухфакторная аутентификация включена для вашей учётной записи.
title: 2FA включена
@ -75,7 +75,7 @@ ru:
title: Один из ваших электронных ключей удалён
webauthn_disabled:
explanation: Аутентификация по электронным ключам деактивирована для вашей учетной записи.
extra: Теперь вход возможен с использованием только лишь одноразового кода, сгенерированного сопряжённым приложением TOTP.
extra: Теперь вход возможен с использованием только с помощью одноразового кода, сгенерированного сопряжённым приложением-аутентификатором.
subject: 'Mastodon: Аутентификация по электронным ключам деактивирована'
title: Вход по электронным ключам деактивирован
webauthn_enabled:

View File

@ -190,6 +190,7 @@ en:
create_relay: Create Relay
create_unavailable_domain: Create Unavailable Domain
create_user_role: Create Role
create_username_block: Create Username Rule
demote_user: Demote User
destroy_announcement: Delete Announcement
destroy_canonical_email_block: Delete Email Block
@ -203,6 +204,7 @@ en:
destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain
destroy_user_role: Destroy Role
destroy_username_block: Delete Username Rule
disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji
disable_relay: Disable Relay
@ -237,6 +239,7 @@ en:
update_report: Update Report
update_status: Update Post
update_user_role: Update Role
update_username_block: Update Username Rule
actions:
approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up from %{target}"
@ -255,6 +258,7 @@ en:
create_relay_html: "%{name} created a relay %{target}"
create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}"
create_user_role_html: "%{name} created %{target} role"
create_username_block_html: "%{name} added rule for usernames containing %{target}"
demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}"
@ -268,6 +272,7 @@ en:
destroy_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
destroy_user_role_html: "%{name} deleted %{target} role"
destroy_username_block_html: "%{name} removed rule for usernames containing %{target}"
disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_relay_html: "%{name} disabled the relay %{target}"
@ -302,6 +307,7 @@ en:
update_report_html: "%{name} updated report %{target}"
update_status_html: "%{name} updated post by %{target}"
update_user_role_html: "%{name} changed %{target} role"
update_username_block_html: "%{name} updated rule for usernames containing %{target}"
deleted_account: deleted account
empty: No logs found.
filter_by_action: Filter by action
@ -1085,6 +1091,25 @@ en:
other: Used by %{count} people over the last week
title: Recommendations & Trends
trending: Trending
username_blocks:
add_new: Add new
block_registrations: Block registrations
comparison:
contains: Contains
equals: Equals
contains_html: Contains %{string}
created_msg: Successfully created username rule
delete: Delete
edit:
title: Edit username rule
matches_exactly_html: Equals %{string}
new:
create: Create rule
title: Create new username rule
no_username_block_selected: No username rules were changed as none were selected
not_permitted: Not permitted
title: Username rules
updated_msg: Successfully updated username rule
warning_presets:
add_new: Add new
delete: Delete

View File

@ -653,8 +653,8 @@ fa:
mark_as_sensitive_description_html: رسانهٔ درون فرستهٔ گزارش شده به عنوان حسّاس علامت خورده و شکایتی ضبط خواهد شد تا بتوانید خلاف‌های آینده از همین حساب را بهتر مدیریت کنید.
other_description_html: دیدن انتخاب های بیشتر برای کنترل رفتار حساب و سفارشی سازی ارتباط با حساب گزارش شده.
resolve_description_html: هیچ کنشی علیه حساب گزارش شده انجام نخواهد شد. هیچ شکایتی ضبط نشده و گزارش بسته خواهد شد.
silence_description_html: این حساب فقط برای کسانی قابل مشاهده خواهد بود که قبلاً آن را دنبال می کنند یا به صورت دستی آن را جستجو می کنند و دسترسی آن را به شدت محدود می کند. همیشه می توان برگرداند. همه گزارش‌های مربوط به این حساب را می‌بندد.
suspend_description_html: اکانت و تمامی محتویات آن غیرقابل دسترسی و در نهایت حذف خواهد شد و تعامل با آن غیر ممکن خواهد بود. قابل برگشت در عرض 30 روز همه گزارش‌های مربوط به این حساب را می‌بندد.
silence_description_html: حساب فقط برای کسانی که از پیش پی می‌گرفتندش یا به صورت دستی به دنیالش گشته‌اند نمایان خواهد بود که رسشش را شدیداً محدود می‌کند. همواره برگشت‌پذیر است. همهٔ گزارش‌ها علیه این حساب را خواهد بست.
suspend_description_html: حساب و همهٔ محتوایش غیرقابل دسترس شده و در نهایت حذف خواهند شد. تعامل با آن ممکن نخواهد بود. بازگشت‌پذیر تا ۳۰ روز. همهٔ گزارش‌ها علیه این حساب را خواهد بست.
actions_description_html: تصمیم گیری کنش اقدامی برای حل این گزارش. در صورت انجام کنش تنبیهی روی حساب گزارش شده، غیر از زمان یکه دستهٔ <strong>هرزنامه</strong> گزیده باشد، برایش آگاهی رایانامه‌ای فرستاده خواهد شد.
actions_description_remote_html: تصمیم بگیرید که چه اقدامی برای حل این گزارش انجام دهید. این فقط بر نحوه ارتباط سرور <strong>شما</strong> با این حساب راه دور و مدیریت محتوای آن تأثیر می گذارد.
actions_no_posts: این گزارش هیچ پست مرتبطی برای حذف ندارد
@ -714,7 +714,7 @@ fa:
actions:
delete_html: پست های توهین آمیز را حذف کنید
mark_as_sensitive_html: رسانه پست های توهین آمیز را به عنوان حساس علامت گذاری کنید
silence_html: دسترسی <strong>@%{acct}</strong> را به شدت محدود کنید و نمایه و محتویات آنها را فقط برای افرادی که قبلاً آنها را دنبال می‌کنند قابل مشاهده کنید یا به صورت دستی نمایه آن را جستجو کنید
silence_html: محدودیت شدید رسش <strong>@%{acct}</strong> با نمایان کردن نماگر و محتوایش فقط به افرادی که از پیش پی می‌گرفتندش و به صورت دستی به دنبالش گشته‌اند
suspend_html: تعلیق <strong>@%{acct}</strong>، غیرقابل دسترس کردن نمایه و محتوای آنها و تعامل با آنها غیر ممکن
close_report: 'علامت گذاری گزارش #%{id} به عنوان حل شده است'
close_reports_html: "<strong>همه</strong> گزارش‌ها در برابر <strong>@%{acct}</strong> را به‌عنوان حل‌وفصل علامت‌گذاری کنید"
@ -1872,6 +1872,7 @@ fa:
edited_at_html: ویراسته در %{date}
errors:
in_reply_not_found: به نظر نمی‌رسد وضعیتی که می‌خواهید به آن پاسخ دهید، وجود داشته باشد.
quoted_status_not_found: به نظر نمی‌رسد فرسته‌ای که می‌خواهید نقلش کنید وجود داشته باشد.
over_character_limit: از حد مجاز %{max} حرف فراتر رفتید
pin_errors:
direct: فرسته‌هایی که فقط برای کاربران اشاره شده نمایانند نمی‌توانند سنجاق شوند
@ -2002,7 +2003,7 @@ fa:
details: 'جزییات ورود:'
explanation: ما ورود به حساب شما را از یک آدرس آی پی جدید شناسایی کرده ایم.
further_actions_html: اگر این شما نبودید، توصیه می کنیم فورا %{action} را فعال کنید و برای ایمن نگه داشتن حساب خود، احراز هویت دو مرحله ای را فعال کنید.
subject: حساب شما از یک آدرس آی پی جدید قابل دسترسی است
subject: نشانی آی‌پی جدیدی به حسابتان دسترسی پیدا کرده
title: یک ورود جدید
terms_of_service_changed:
agreement: با ادامه استفاده از %{domain}، با این شرایط موافقت می کنید. اگر با شرایط به‌روزرسانی شده مخالف هستید، می‌توانید در هر زمان با حذف حساب خود، قرارداد خود را با %{domain} فسخ کنید.

View File

@ -2001,6 +2001,7 @@ ga:
edited_at_html: "%{date} curtha in eagar"
errors:
in_reply_not_found: Is cosúil nach ann don phostáil a bhfuil tú ag iarraidh freagra a thabhairt air.
quoted_status_not_found: Is cosúil nach bhfuil an post atá tú ag iarraidh a lua ann.
over_character_limit: teorainn carachtar %{max} sáraithe
pin_errors:
direct: Ní féidir postálacha nach bhfuil le feiceáil ach ag úsáideoirí luaite a phinnáil

View File

@ -1611,7 +1611,7 @@ ru:
limit: Вы достигли максимального количества списков
login_activities:
authentication_methods:
otp: приложения двухфакторной аутентификации
otp: приложения для генерации кодов
password: пароля
webauthn: электронного ключа
description_html: Если вы заметили действия, которых не совершали, вам следует сменить пароль и включить двухфакторную аутентификацию.
@ -1621,11 +1621,18 @@ ru:
title: История входов
mail_subscriptions:
unsubscribe:
action: Да, отписаться
action: Да, я хочу отписаться
complete: Подписка отменена
confirmation_html: Вы точно желаете отписаться от всех уведомления типа «%{type}», доставляемых из сервера Mastodon %{domain} на ваш адрес электронной почты %{email}? Вы всегда сможете подписаться снова в <a href="%{settings_path}">настройках e-mail уведомлений</a>.
resubscribe_html: Если вы отписались от рассылки по ошибке, вы можете повторно подписаться на рассылку в настройках <a href="%{settings_path}">настроек почтовых уведомлений</a>.
success_html: Вы больше не будете получать %{type} для Mastodon на %{domain} на вашу электронную почту %{email}.
confirmation_html: Вы уверены в том, что хотите отписаться от всех %{type}, которые вы получаете на адрес %{email} для учётной записи на сервере Mastodon %{domain}? Вы всегда сможете подписаться снова в <a href="%{settings_path}">настройках уведомлений по электронной почте</a>.
emails:
notification_emails:
favourite: уведомлений о добавлении ваших постов в избранное
follow: уведомлений о новых подписчиках
follow_request: уведомлений о новых запросах на подписку
mention: уведомлений о новых упоминаниях
reblog: уведомлений о продвижении ваших постов
resubscribe_html: Если вы отписались по ошибке и хотите подписаться снова, перейдите на страницу <a href="%{settings_path}">настройки уведомлений по электронной почте</a>.
success_html: Вы отказались от %{type}, которые вы получали на адрес %{email} для вашей учётной записи на сервере Mastodon %{domain}.
title: Отписаться
media_attachments:
validations:
@ -1721,13 +1728,13 @@ ru:
trillion: трлн
unit: ''
otp_authentication:
code_hint: Для подтверждения введите код, сгенерированный приложением-аутентификатором
description_html: Подключив <strong>двуфакторную авторизацию</strong>, для входа в свою учётную запись вам будет необходим смартфон и приложение-аутентификатор на нём, которое будет генерировать специальные временные коды. Без этих кодов войти в учётную запись не получиться, даже если все данные верны, что существенно увеличивает безопасность вашей учётной записи.
code_hint: Для подтверждения введите код из приложения-аутентификатора
description_html: Подключите <strong>двухфакторную аутентификацию</strong> с использованием специального приложения-аутентификатора, и тогда для входа в вашу учётную запись необходимо будет иметь при себе смартфон, который будет генерировать одноразовые коды.
enable: Включить
instructions_html: "<strong>Отсканируйте этот QR-код с помощью приложения-аутентификатора</strong>, такого как Google Authenticator, Яндекс.Ключ или andOTP. После сканирования и добавления, приложение начнёт генерировать коды, которые потребуется вводить для завершения входа в учётную запись."
manual_instructions: 'Если отсканировать QR-код не получается или не представляется возможным, вы можете ввести ключ настройки вручную:'
setup: Настроить
wrong_code: Введенный код недействителен! Время сервера и время устройства правильно?
instructions_html: "<strong>Откройте Google Authenticator или другое приложение-аутентификатор на вашем смартфоне и отсканируйте этот QR-код</strong>. В дальнейшем это приложение будет генерировать одноразовые коды, которые потребуется вводить для подтверждения входа в вашу учётную запись."
manual_instructions: 'Если отсканировать QR-код не получается, введите секретный ключ вручную:'
setup: Подключить
wrong_code: Одноразовый код, который вы ввели, не подходит! Совпадает ли время на устройстве с временем на сервере?
pagination:
newer: Позже
next: Вперёд
@ -1751,14 +1758,14 @@ ru:
posting_defaults: Предустановки для новых постов
public_timelines: Публичные ленты
privacy:
hint_html: "<strong>Настройте, как вы хотите, чтобы ваш профиль и ваши сообщения были найдены.</strong> Различные функции в Mastodon могут помочь вам охватить более широкую аудиторию, если они включены. Уделите время изучению этих настроек, чтобы убедиться, что они подходят для вашего случая использования."
hint_html: "<strong>Здесь вы можете определить то, как другие смогут обнаружить ваши посты и ваш профиль.</strong> Множество разных функций в Mastodon существуют для того, чтобы помочь вам выйти на более широкую аудиторию, если вы того захотите. Ознакомьтесь с этими настройками и в случае необходимости измените их согласно вашим желаниям."
privacy: Приватность
privacy_hint_html: Определите, какую информацию вы хотите раскрыть в интересах других. Люди находят интересные профили и приложения, просматривая список подписчиков других людей и узнавая, из каких приложений они публикуют свои сообщения, но вы можете предпочесть скрыть это.
privacy_hint_html: Решите, сколько данных о себе вы готовы раскрыть ради того, чтобы они пошли на пользу другим. Просматривая ваши подписки, кто-то может обнаружить профили интересных людей, а ещё кто-нибудь может найти своё любимое приложение, увидев его название рядом с вашими постами. Тем не менее вы можете предпочесть не раскрывать эту информацию.
reach: Видимость
reach_hint_html: Укажите, хотите ли вы, чтобы новые люди обнаруживали вас и могли следить за вами. Хотите ли вы, чтобы ваши сообщения появлялись на экране Обзора? Хотите ли вы, чтобы другие люди видели вас в своих рекомендациях? Хотите ли вы автоматически принимать всех новых подписчиков или иметь возможность детально контролировать каждого из них?
reach_hint_html: Решите, нужна ли вам новая аудитория и новые подписчики. Настройте по своему желанию, показывать ли ваши посты в разделе «Актуальное», рекомендовать ли ваш профиль другим людям, принимать ли всех новых подписчиков автоматически или рассматривать каждый запрос на подписку в отдельности.
search: Поиск
search_hint_html: Определите, как вас могут найти. Хотите ли вы, чтобы люди находили вас по тому, о чём вы публично писали? Хотите ли вы, чтобы люди за пределами Mastodon находили ваш профиль при поиске в Интернете? Следует помнить, что полное исключение из всех поисковых систем не может быть гарантировано для публичной информации.
title: Приватность и доступ
title: Приватность и видимость
privacy_policy:
title: Политика конфиденциальности
reactions:

View File

@ -160,6 +160,10 @@ en:
name: Public name of the role, if role is set to be displayed as a badge
permissions_as_keys: Users with this role will have access to...
position: Higher role decides conflict resolution in certain situations. Certain actions can only be performed on roles with a lower priority
username_block:
allow_with_approval: Instead of preventing sign-up outright, matching sign-ups will require your approval
comparison: Please be mindful of the Scunthorpe Problem when blocking partial matches
username: Will be matched regardless of casing and common homoglyphs like "4" for "a" or "3" for "e"
webhook:
events: Select events to send
template: Compose your own JSON payload using variable interpolation. Leave blank for default JSON.
@ -371,6 +375,10 @@ en:
name: Name
permissions_as_keys: Permissions
position: Priority
username_block:
allow_with_approval: Allow registrations with approval
comparison: Method of comparison
username: Word to match
webhook:
events: Enabled events
template: Payload template

View File

@ -56,7 +56,7 @@ fa:
scopes: واسط‌های برنامه‌نویسی که این برنامه به آن دسترسی دارد. اگر بالاترین سطح دسترسی را انتخاب کنید، دیگر نیازی به انتخاب سطح‌های پایینی ندارید.
setting_aggregate_reblogs: برای تقویت‌هایی که به تازگی برایتان نمایش داده شده‌اند، تقویت‌های بیشتر را نمایش نده (فقط روی تقویت‌های اخیر تأثیر می‌گذارد)
setting_always_send_emails: در حالت عادی آگاهی‌های رایانامه‌ای هنگامی که فعّالانه از ماستودون استفاده می‌کنید فرستاده نمی‌شوند
setting_default_quote_policy: کاربران اشاره شده همواره مجاز به نقل قولند. این تنظیمات تنها روی فرسته‌های ایجاد شده با نگارش بعدی ماستودون موثّر است، ولی می‌توانید ترجیحاتتان را پیشاپیش بگزینید
setting_default_quote_policy: کاربران اشاره شده همواره مجاز به نقلند. این تنظیمات تنها روی فرسته‌های ایجاد شده با نگارش بعدی ماستودون موثّر است، ولی می‌توانید ترجیحاتتان را پیشاپیش بگزینید
setting_default_sensitive: تصاویر حساس به طور پیش‌فرض پنهان هستند و می‌توانند با یک کلیک آشکار شوند
setting_display_media_default: تصویرهایی را که به عنوان حساس علامت زده شده‌اند پنهان کن
setting_display_media_hide_all: همیشه همهٔ عکس‌ها و ویدیوها را پنهان کن
@ -150,6 +150,9 @@ fa:
min_age: نباید کم‌تر از کمینهٔ زمان لازم از سوی قوانین حقوقیتان باشد.
user:
chosen_languages: اگر انتخاب کنید، تنها نوشته‌هایی که به زبان‌های برگزیدهٔ شما نوشته شده‌اند در فهرست نوشته‌های عمومی نشان داده می‌شوند
date_of_birth:
one: برای استفاده از %{domain} باید مطمئن شویم کمینه %{count} سال را دارید. این مورد را ذخیره نخواهیم کرد.
other: برای استفاده از %{domain} باید مطمئن شویم کمینه %{count} سال را دارید. این مورد را ذخیره نخواهیم کرد.
role: نقش کنترل می کند که کاربر چه مجوزهایی دارد.
user_role:
color: رنگی که برای نقش در سرتاسر UI استفاده می شود، به عنوان RGB در قالب هگز
@ -230,7 +233,7 @@ fa:
setting_boost_modal: نمایش پیغام تأیید پیش از تقویت کردن
setting_default_language: زبان نوشته‌های شما
setting_default_privacy: حریم خصوصی نوشته‌ها
setting_default_quote_policy: افراد مجاز به نقل قول
setting_default_quote_policy: افراد مجاز به نقل
setting_default_sensitive: همیشه تصاویر را به عنوان حساس علامت بزن
setting_delete_modal: نمایش پیغام تأیید پیش از پاک کردن یک نوشته
setting_disable_hover_cards: از کار انداختن پیش‌نمایش نمایه هنگام رفتن رویش

View File

@ -1872,6 +1872,7 @@ sv:
edited_at_html: 'Ändrad: %{date}'
errors:
in_reply_not_found: Inlägget du försöker svara på verkar inte existera.
quoted_status_not_found: Inlägget du försöker svara på verkar inte existera.
over_character_limit: teckengräns på %{max} har överskridits
pin_errors:
direct: Inlägg som endast är synliga för nämnda användare kan inte fästas

View File

@ -59,6 +59,7 @@ SimpleNavigation::Configuration.run do |navigation|
current_user.can?(:manage_federation)
}
s.item :email_domain_blocks, safe_join([material_symbol('mail'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :username_blocks, safe_join([material_symbol('supervised_user_circle_off'), t('admin.username_blocks.title')]), admin_username_blocks_path, highlights_on: %r{/admin/username_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :ip_blocks, safe_join([material_symbol('hide_source'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :action_logs, safe_join([material_symbol('list'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) }
end

View File

@ -230,4 +230,10 @@ namespace :admin do
end
resources :software_updates, only: [:index]
resources :username_blocks, except: [:show, :destroy] do
collection do
post :batch
end
end
end

View File

@ -20,28 +20,6 @@ defaults: &defaults
trends: true
trends_as_landing_page: true
trendable_by_default: false
reserved_usernames:
- abuse
- account
- accounts
- admin
- administration
- administrator
- admins
- help
- helpdesk
- instance
- mod
- moderator
- moderators
- mods
- owner
- root
- security
- server
- staff
- support
- webmaster
disallowed_hashtags: # space separated string or list of hashtags without the hash
bootstrap_timeline_accounts: ''
activity_api_enabled: true

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class CreateUsernameBlocks < ActiveRecord::Migration[8.0]
def change
create_table :username_blocks do |t|
t.string :username, null: false
t.string :normalized_username, null: false
t.boolean :exact, null: false, default: false
t.boolean :allow_with_approval, null: false, default: false
t.timestamps
end
add_index :username_blocks, 'lower(username)', unique: true, name: 'index_username_blocks_on_username_lower_btree'
add_index :username_blocks, :normalized_username
reversible do |dir|
dir.up do
load Rails.root.join('db', 'seeds', '05_blocked_usernames.rb')
end
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
ActiveRecord::Schema[8.0].define(version: 2025_07_17_003848) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -1238,6 +1238,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "updated_at", null: false
end
create_table "username_blocks", force: :cascade do |t|
t.string "username", null: false
t.string "normalized_username", null: false
t.boolean "exact", default: false, null: false
t.boolean "allow_with_approval", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index "lower((username)::text)", name: "index_username_blocks_on_username_lower_btree", unique: true
t.index ["normalized_username"], name: "index_username_blocks_on_normalized_username"
end
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.datetime "created_at", precision: nil, null: false

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
%w(
abuse
account
accounts
admin
administration
administrator
admins
help
helpdesk
instance
mod
moderator
moderators
mods
owner
root
security
server
staff
support
webmaster
).each do |str|
UsernameBlock.create_with(username: str, exact: true).find_or_create_by(username: str)
end
%w(
mastodon
mastadon
).each do |str|
UsernameBlock.create_with(username: str, exact: false).find_or_create_by(username: str)
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:username_block) do
username { sequence(:email) { |i| "#{i}#{Faker::Internet.username}" } }
exact false
allow_with_approval false
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe UsernameBlock do
describe '.matches?' do
context 'when there is an exact block' do
before do
Fabricate(:username_block, username: 'carriage', exact: true)
end
it 'returns true on exact match' do
expect(described_class.matches?('carriage')).to be true
end
it 'returns true on case insensitive match' do
expect(described_class.matches?('CaRRiagE')).to be true
end
it 'returns true on homoglyph match' do
expect(described_class.matches?('c4rr14g3')).to be true
end
it 'returns false on partial match' do
expect(described_class.matches?('foo_carriage')).to be false
end
it 'returns false on no match' do
expect(described_class.matches?('foo')).to be false
end
end
context 'when there is a partial block' do
before do
Fabricate(:username_block, username: 'carriage', exact: false)
end
it 'returns true on exact match' do
expect(described_class.matches?('carriage')).to be true
end
it 'returns true on case insensitive match' do
expect(described_class.matches?('CaRRiagE')).to be true
end
it 'returns true on homoglyph match' do
expect(described_class.matches?('c4rr14g3')).to be true
end
it 'returns true on suffix match' do
expect(described_class.matches?('foo_carriage')).to be true
end
it 'returns true on prefix match' do
expect(described_class.matches?('carriage_foo')).to be true
end
it 'returns false on no match' do
expect(described_class.matches?('foo')).to be false
end
end
end
end

View File

@ -42,14 +42,6 @@ RSpec.describe WorkerBatch do
it 'does not persist the job IDs' do
expect(subject.jobs).to eq []
end
context 'when async refresh is connected' do
let(:async_refresh) { AsyncRefresh.new(async_refresh_key) }
it 'immediately marks the async refresh as finished' do
expect(async_refresh.reload.finished?).to be true
end
end
end
context 'when called with an array of job IDs' do
@ -71,7 +63,7 @@ RSpec.describe WorkerBatch do
before do
subject.connect(async_refresh_key, threshold: 0.5) if async_refresh.present?
subject.add_jobs(%w(foo bar baz))
subject.remove_job('foo')
subject.remove_job('foo', increment: true)
end
it 'removes the job from pending jobs' do

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Username Blocks' do
describe 'GET /admin/username_blocks' do
before { sign_in Fabricate(:admin_user) }
it 'returns http success' do
get admin_username_blocks_path
expect(response)
.to have_http_status(200)
end
end
describe 'POST /admin/username_blocks' do
before { sign_in Fabricate(:admin_user) }
it 'gracefully handles invalid nested params' do
post admin_username_blocks_path(username_block: 'invalid')
expect(response)
.to have_http_status(400)
end
it 'creates a username block' do
post admin_username_blocks_path(username_block: { username: 'banana', comparison: 'contains', allow_with_approval: '0' })
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(UsernameBlock.find_by(username: 'banana'))
.to_not be_nil
end
end
describe 'POST /admin/username_blocks/batch' do
before { sign_in Fabricate(:admin_user) }
let(:username_blocks) { Fabricate.times(2, :username_block) }
it 'gracefully handles invalid nested params' do
post batch_admin_username_blocks_path(form_username_block_batch: 'invalid')
expect(response)
.to redirect_to(admin_username_blocks_path)
end
it 'deletes selected username blocks' do
post batch_admin_username_blocks_path(form_username_block_batch: { username_block_ids: username_blocks.map(&:id) }, delete: '1')
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(UsernameBlock.where(id: username_blocks.map(&:id)))
.to be_empty
end
end
describe 'GET /admin/username_blocks/new' do
before { sign_in Fabricate(:admin_user) }
it 'returns http success' do
get new_admin_username_block_path
expect(response)
.to have_http_status(200)
end
end
describe 'GET /admin/username_blocks/:id/edit' do
before { sign_in Fabricate(:admin_user) }
let(:username_block) { Fabricate(:username_block) }
it 'returns http success' do
get edit_admin_username_block_path(username_block)
expect(response)
.to have_http_status(200)
end
end
describe 'PUT /admin/username_blocks/:id' do
before { sign_in Fabricate(:admin_user) }
let(:username_block) { Fabricate(:username_block, username: 'banana') }
it 'updates username block' do
put admin_username_block_path(username_block, username_block: { username: 'bebebe' })
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(username_block.reload.username)
.to eq 'bebebe'
end
end
end

View File

@ -10,8 +10,13 @@ RSpec.describe UnreservedUsernameValidator do
attr_accessor :username
validates_with UnreservedUsernameValidator
def self.name
'Foo'
end
end
end
let(:record) { record_class.new }
describe '#validate' do
@ -114,7 +119,7 @@ RSpec.describe UnreservedUsernameValidator do
end
def stub_reserved_usernames(value)
allow(Setting).to receive(:[]).with('reserved_usernames').and_return(value)
value&.each { |str| Fabricate(:username_block, username: str, exact: true) }
end
end
end