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) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1131.0) aws-partitions (1.1135.0)
aws-sdk-core (3.215.1) aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
@ -233,7 +233,7 @@ GEM
fabrication (3.0.0) fabrication (3.0.0)
faker (3.5.2) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.2) faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@ -345,7 +345,7 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.13.0) json (2.13.2)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.16.7) json-jwt (1.16.7)
activesupport (>= 4.2) activesupport (>= 4.2)
@ -438,7 +438,7 @@ GEM
mime-types (3.7.0) mime-types (3.7.0)
logger logger
mime-types-data (~> 3.2025, >= 3.2025.0507) 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_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.25.5)
@ -601,13 +601,13 @@ GEM
ox (2.14.23) ox (2.14.23)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.8.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.9) pg (1.6.0)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.54.0) playwright-ruby-client (1.54.0)
@ -731,7 +731,7 @@ GEM
railties (>= 5.2) railties (>= 5.2)
rexml (3.4.1) rexml (3.4.1)
rotp (6.3.0) rotp (6.3.0)
rouge (4.5.2) rouge (4.6.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (3.1.0) rqrcode (3.1.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
@ -868,7 +868,7 @@ GEM
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
sysexits (1.2.0) sysexits (1.2.0)
temple (0.10.3) temple (0.10.4)
terminal-table (4.0.0) terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1) terrapin (1.1.1)
@ -1108,4 +1108,4 @@ RUBY VERSION
ruby 3.4.1p0 ruby 3.4.1p0
BUNDLED WITH 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) add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies? elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key)) 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 end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)

View File

@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end end
when 'UserRole' when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id) 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' when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' 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 { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { import {
fetchContext, fetchContext,
completeContextRefresh, completeContextRefresh,
@ -22,8 +20,7 @@ const messages = defineMessages({
export const RefreshController: React.FC<{ export const RefreshController: React.FC<{
statusId: string; statusId: string;
withBorder?: boolean; }> = ({ statusId }) => {
}> = ({ statusId, withBorder }) => {
const refresh = useAppSelector( const refresh = useAppSelector(
(state) => state.contexts.refreshing[statusId], (state) => state.contexts.refreshing[statusId],
); );
@ -78,12 +75,7 @@ export const RefreshController: React.FC<{
if (ready && !loading) { if (ready && !loading) {
return ( return (
<button <button className='load-more load-gap' onClick={handleClick}>
className={classNames('load-more load-gap', {
'timeline-hint--with-descendants': withBorder,
})}
onClick={handleClick}
>
<FormattedMessage <FormattedMessage
id='status.context.load_new_replies' id='status.context.load_new_replies'
defaultMessage='New replies available' defaultMessage='New replies available'
@ -98,9 +90,7 @@ export const RefreshController: React.FC<{
return ( return (
<div <div
className={classNames('load-more load-gap', { className='load-more load-gap'
'timeline-hint--with-descendants': withBorder,
})}
aria-busy aria-busy
aria-live='polite' aria-live='polite'
aria-label={intl.formatMessage(messages.loading)} aria-label={intl.formatMessage(messages.loading)}

View File

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

View File

@ -235,7 +235,7 @@
"confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟", "confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
"confirmations.logout.title": "خروج؟", "confirmations.logout.title": "خروج؟",
"confirmations.missing_alt_text.confirm": "متن جایگزین را اضافه کنید", "confirmations.missing_alt_text.confirm": "متن جایگزین را اضافه کنید",
"confirmations.missing_alt_text.message": "پست شما حاوی رسانه بدون متن جایگزین است. افزودن توضیحات کمک می کند تا محتوای شما برای افراد بیشتری قابل دسترسی باشد.", "confirmations.missing_alt_text.message": "فرسته‌تان رسانه‌هایی بدون متن جایگزین دارد. افزودن شرح به دسترس‌پذیر شدن محتوایتان برای افراد بیش‌تری کمک می‌کند.",
"confirmations.missing_alt_text.secondary": "به هر حال پست کن", "confirmations.missing_alt_text.secondary": "به هر حال پست کن",
"confirmations.missing_alt_text.title": "متن جایگزین اضافه شود؟", "confirmations.missing_alt_text.title": "متن جایگزین اضافه شود؟",
"confirmations.mute.confirm": "خموش", "confirmations.mute.confirm": "خموش",
@ -424,7 +424,7 @@
"hints.profiles.see_more_followers": "دیدن پی‌گیرندگان بیش‌تر روی {domain}", "hints.profiles.see_more_followers": "دیدن پی‌گیرندگان بیش‌تر روی {domain}",
"hints.profiles.see_more_follows": "دیدن پی‌گرفته‌های بیش‌تر روی {domain}", "hints.profiles.see_more_follows": "دیدن پی‌گرفته‌های بیش‌تر روی {domain}",
"hints.profiles.see_more_posts": "دیدن فرسته‌های بیش‌تر روی {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_reblogs": "نمایش تقویت‌ها",
"home.column_settings.show_replies": "نمایش پاسخ‌ها", "home.column_settings.show_replies": "نمایش پاسخ‌ها",
"home.hide_announcements": "نهفتن اعلامیه‌ها", "home.hide_announcements": "نهفتن اعلامیه‌ها",
@ -845,6 +845,8 @@
"status.bookmark": "نشانک", "status.bookmark": "نشانک",
"status.cancel_reblog_private": "ناتقویت", "status.cancel_reblog_private": "ناتقویت",
"status.cannot_reblog": "این فرسته قابل تقویت نیست", "status.cannot_reblog": "این فرسته قابل تقویت نیست",
"status.context.load_new_replies": "پاسخ‌های جدیدی موجودند",
"status.context.loading": "بررسی کردن برای پاسخ‌های بیش‌تر",
"status.continued_thread": "رشتهٔ دنباله دار", "status.continued_thread": "رشتهٔ دنباله دار",
"status.copy": "رونوشت از پیوند فرسته", "status.copy": "رونوشت از پیوند فرسته",
"status.delete": "حذف", "status.delete": "حذف",
@ -873,7 +875,7 @@
"status.quote_error.filtered": "نهفته بنا بر یکی از پالایه‌هایتان", "status.quote_error.filtered": "نهفته بنا بر یکی از پالایه‌هایتان",
"status.quote_error.not_found": "این فرسته قابل نمایش نیست.", "status.quote_error.not_found": "این فرسته قابل نمایش نیست.",
"status.quote_error.pending_approval": "این فرسته منظر تأیید نگارندهٔ اصلی است.", "status.quote_error.pending_approval": "این فرسته منظر تأیید نگارندهٔ اصلی است.",
"status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی فرسته اجازهٔ نقل قولش را نمی‌دهد این فرسته قابل نمایش نیست.", "status.quote_error.rejected": "از آن‌جا که نگارندهٔ اصلی این فرسته اجازهٔ نقلش را نمی‌دهد قابل نمایش نیست.",
"status.quote_error.removed": "این فرسته به دست نگارنده‌اش برداشته شده.", "status.quote_error.removed": "این فرسته به دست نگارنده‌اش برداشته شده.",
"status.quote_error.unauthorized": "از آن‌جا که اجازهٔ دیدن این فرسته را ندارید قابل نمایش نیست.", "status.quote_error.unauthorized": "از آن‌جا که اجازهٔ دیدن این فرسته را ندارید قابل نمایش نیست.",
"status.quote_post_author": "فرسته توسط {name}", "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.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.edit.title": "Descartar mudanças no seu post?",
"confirmations.discard_draft.post.cancel": "Continuar rascunho", "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.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.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", "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.copy_stacktrace": "Copiar dados do erro para área de transferência",
"errors.unexpected_crash.report_issue": "Reportar problema", "errors.unexpected_crash.report_issue": "Reportar problema",
"explore.suggested_follows": "Pessoas", "explore.suggested_follows": "Pessoas",
"explore.title": "Em alta",
"explore.trending_links": "Notícias", "explore.trending_links": "Notícias",
"explore.trending_statuses": "Publicações", "explore.trending_statuses": "Publicações",
"explore.trending_tags": "Hashtags", "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_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.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.", "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.lists": "Listas",
"navigation_bar.logout": "Sair", "navigation_bar.logout": "Sair",
"navigation_bar.moderation": "Moderação", "navigation_bar.moderation": "Moderação",
"navigation_bar.more": "Mais",
"navigation_bar.mutes": "Usuários silenciados", "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.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", "navigation_bar.preferences": "Preferências",

View File

@ -845,6 +845,8 @@
"status.bookmark": "Bokmärk", "status.bookmark": "Bokmärk",
"status.cancel_reblog_private": "Sluta boosta", "status.cancel_reblog_private": "Sluta boosta",
"status.cannot_reblog": "Detta inlägg kan inte boostas", "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.continued_thread": "Fortsatt tråd",
"status.copy": "Kopiera inläggslänk", "status.copy": "Kopiera inläggslänk",
"status.delete": "Radera", "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 end
def local_preflight_check!(status) def local_preflight_check!(status)
return unless spammy_texts.any? { |spammy_text| status.text.include?(spammy_text) } return unless considered_spam?(status)
return unless suspicious_reply_or_mention?(status)
return unless status.account.created_at >= ACCOUNT_AGE_EXEMPTION.ago
report_if_needed!(status.account) report_if_needed!(status.account)
@ -44,10 +42,26 @@ class Antispam
private 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 def spammy_texts
redis.smembers('antispam:spammy_texts') redis.smembers('antispam:spammy_texts')
end end
def all_time_spammy_texts
redis.smembers('antispam:all_time_spammy_texts')
end
def suspicious_reply_or_mention?(status) def suspicious_reply_or_mention?(status)
parent = status.thread parent = status.thread
return true if parent.present? && !Follow.exists?(account_id: parent.account_id, target_account: status.account_id) 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_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze, update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.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 }.freeze
attr_reader :params attr_reader :params

View File

@ -33,7 +33,7 @@ module Status::FetchRepliesConcern
def should_fetch_replies? def should_fetch_replies?
# we aren't brand new, and we haven't fetched replies since the debounce window # 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 fetched_replies_at.nil? || fetched_replies_at <= FETCH_REPLIES_COOLDOWN_MINUTES.ago
) )
end 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 def set_approved
self.approved = begin 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 false
else else
open_registrations? || valid_invitation? || external? open_registrations? || valid_invitation? || external?
@ -499,6 +499,10 @@ class User < ApplicationRecord
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip) EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
end end
def sign_up_username_requires_approval?
account.username? && UsernameBlock.matches?(account.username, allow_with_approval: true)
end
def open_registrations? def open_registrations?
Setting.registrations_mode == 'open' Setting.registrations_mode == 'open'
end 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 begin
Thread.current[:batch] = self Thread.current[:batch] = self
yield yield(self)
ensure ensure
Thread.current[:batch] = nil Thread.current[:batch] = nil
end end
@ -33,10 +33,7 @@ class WorkerBatch
# Add jobs to the batch. Usually when the batch is created. # Add jobs to the batch. Usually when the batch is created.
# @param [Array<String>] jids # @param [Array<String>] jids
def add_jobs(jids) def add_jobs(jids)
if jids.blank? return if jids.empty?
finish!
return
end
redis.multi do |pipeline| redis.multi do |pipeline|
pipeline.sadd(key('jobs'), jids) 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. # Remove a job from the batch, such as when it's been processed or it has failed.
# @param [String] jid # @param [String] jid
def remove_job(jid) def remove_job(jid, increment: false)
_, pending, processed, async_refresh_key, threshold = redis.multi do |pipeline| _, pending, processed, async_refresh_key, threshold = redis.multi do |pipeline|
pipeline.srem(key('jobs'), jid) pipeline.srem(key('jobs'), jid)
pipeline.hincrby(key, 'pending', -1) pipeline.hincrby(key, 'pending', -1)
@ -57,10 +54,8 @@ class WorkerBatch
pipeline.hget(key, 'threshold') pipeline.hget(key, 'threshold')
end end
if async_refresh_key.present? async_refresh = AsyncRefresh.new(async_refresh_key) if async_refresh_key.present?
async_refresh = AsyncRefresh.new(async_refresh_key) async_refresh&.increment_result_count(by: 1) if increment
async_refresh.increment_result_count(by: 1)
end
if pending.zero? || processed >= (threshold || 1.0).to_f * (processed + pending) if pending.zero? || processed >= (threshold || 1.0).to_f * (processed + pending)
async_refresh&.finish! 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 # Limit of replies to fetch per status
MAX_REPLIES = (ENV['FETCH_REPLIES_MAX_SINGLE'] || 500).to_i 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 @status_uri = status_uri
super super

View File

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

View File

@ -28,14 +28,6 @@ class UnreservedUsernameValidator < ActiveModel::Validator
end end
def settings_username_reserved? def settings_username_reserved?
settings_has_reserved_usernames? && settings_reserves_username? UsernameBlock.matches?(@username, allow_with_approval: false)
end
def settings_has_reserved_usernames?
Setting.reserved_usernames.present?
end
def settings_reserves_username?
Setting.reserved_usernames.include?(@username.downcase)
end end
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 MAX_PAGES = (ENV['FETCH_REPLIES_MAX_PAGES'] || 500).to_i
def perform(root_status_id, options = {}) def perform(root_status_id, options = {})
@batch = WorkerBatch.new(options['batch_id'])
@root_status = Status.remote.find_by(id: root_status_id) @root_status = Status.remote.find_by(id: root_status_id)
return unless @root_status&.should_fetch_replies? return unless @root_status&.should_fetch_replies?
@root_status.touch(:fetched_replies_at) @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 # Workers shouldn't be returning anything, but this is used in tests
fetched_uris fetched_uris
ensure
@batch.remove_job(jid)
end end
private private
@ -53,9 +57,10 @@ class ActivityPub::FetchAllRepliesWorker
# status URI, or the prefetched body of the Note object # status URI, or the prefetched body of the Note object
def get_replies(status, max_pages, options = {}) def get_replies(status, max_pages, options = {})
replies_collection_or_uri = get_replies_uri(status) replies_collection_or_uri = get_replies_uri(status)
return if replies_collection_or_uri.nil? 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 end
# Get the URI of the replies collection of a status # Get the URI of the replies collection of a status
@ -78,9 +83,12 @@ class ActivityPub::FetchAllRepliesWorker
# @param root_status_uri [String] # @param root_status_uri [String]
def get_root_replies(root_status_uri, options = {}) def get_root_replies(root_status_uri, options = {})
root_status_body = fetch_resource(root_status_uri, true) root_status_body = fetch_resource(root_status_uri, true)
return if root_status_body.nil? return if root_status_body.nil?
FetchReplyWorker.perform_async(root_status_uri, { **options.deep_stringify_keys, 'prefetched_body' => root_status_body }) @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) get_replies(root_status_body, MAX_PAGES, options)
end end

View File

@ -7,9 +7,9 @@ class FetchReplyWorker
sidekiq_options queue: 'pull', retry: 3 sidekiq_options queue: 'pull', retry: 3
def perform(child_url, options = {}) def perform(child_url, options = {})
batch = WorkerBatch.new(options.delete('batch_id')) if options['batch_id'] 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 ensure
batch&.remove_job(jid) batch&.remove_job(jid, increment: result.present?)
end end
end end

View File

@ -115,8 +115,10 @@ class MoveWorker
def carry_mutes_over! def carry_mutes_over!
@source_account.muted_by_relationships.where(account: Account.local).find_each do |mute| @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)
add_account_note_if_needed!(mute.account, 'move_handler.carry_mutes_over_text') 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 rescue => e
@deferred_error = e @deferred_error = e
end 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 - '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 - '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.terms_of_service.generate' # temporarily disabled
- 'admin.username_blocks.matches_exactly_html'
- 'admin.username_blocks.contains_html'
ignore_inconsistent_interpolations: ignore_inconsistent_interpolations:
- '*.one' - '*.one'

View File

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

View File

@ -190,6 +190,7 @@ en:
create_relay: Create Relay create_relay: Create Relay
create_unavailable_domain: Create Unavailable Domain create_unavailable_domain: Create Unavailable Domain
create_user_role: Create Role create_user_role: Create Role
create_username_block: Create Username Rule
demote_user: Demote User demote_user: Demote User
destroy_announcement: Delete Announcement destroy_announcement: Delete Announcement
destroy_canonical_email_block: Delete Email Block destroy_canonical_email_block: Delete Email Block
@ -203,6 +204,7 @@ en:
destroy_status: Delete Post destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain destroy_unavailable_domain: Delete Unavailable Domain
destroy_user_role: Destroy Role destroy_user_role: Destroy Role
destroy_username_block: Delete Username Rule
disable_2fa_user: Disable 2FA disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji disable_custom_emoji: Disable Custom Emoji
disable_relay: Disable Relay disable_relay: Disable Relay
@ -237,6 +239,7 @@ en:
update_report: Update Report update_report: Update Report
update_status: Update Post update_status: Update Post
update_user_role: Update Role update_user_role: Update Role
update_username_block: Update Username Rule
actions: actions:
approve_appeal_html: "%{name} approved moderation decision appeal from %{target}" approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up 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_relay_html: "%{name} created a relay %{target}"
create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}" create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}"
create_user_role_html: "%{name} created %{target} role" 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}" demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}" destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{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_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}" destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
destroy_user_role_html: "%{name} deleted %{target} role" 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_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}" disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_relay_html: "%{name} disabled the relay %{target}" disable_relay_html: "%{name} disabled the relay %{target}"
@ -302,6 +307,7 @@ en:
update_report_html: "%{name} updated report %{target}" update_report_html: "%{name} updated report %{target}"
update_status_html: "%{name} updated post by %{target}" update_status_html: "%{name} updated post by %{target}"
update_user_role_html: "%{name} changed %{target} role" update_user_role_html: "%{name} changed %{target} role"
update_username_block_html: "%{name} updated rule for usernames containing %{target}"
deleted_account: deleted account deleted_account: deleted account
empty: No logs found. empty: No logs found.
filter_by_action: Filter by action filter_by_action: Filter by action
@ -1085,6 +1091,25 @@ en:
other: Used by %{count} people over the last week other: Used by %{count} people over the last week
title: Recommendations & Trends title: Recommendations & Trends
trending: Trending 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: warning_presets:
add_new: Add new add_new: Add new
delete: Delete delete: Delete

View File

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

View File

@ -2001,6 +2001,7 @@ ga:
edited_at_html: "%{date} curtha in eagar" edited_at_html: "%{date} curtha in eagar"
errors: errors:
in_reply_not_found: Is cosúil nach ann don phostáil a bhfuil tú ag iarraidh freagra a thabhairt air. 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 over_character_limit: teorainn carachtar %{max} sáraithe
pin_errors: pin_errors:
direct: Ní féidir postálacha nach bhfuil le feiceáil ach ag úsáideoirí luaite a phinnáil 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: Вы достигли максимального количества списков limit: Вы достигли максимального количества списков
login_activities: login_activities:
authentication_methods: authentication_methods:
otp: приложения двухфакторной аутентификации otp: приложения для генерации кодов
password: пароля password: пароля
webauthn: электронного ключа webauthn: электронного ключа
description_html: Если вы заметили действия, которых не совершали, вам следует сменить пароль и включить двухфакторную аутентификацию. description_html: Если вы заметили действия, которых не совершали, вам следует сменить пароль и включить двухфакторную аутентификацию.
@ -1621,11 +1621,18 @@ ru:
title: История входов title: История входов
mail_subscriptions: mail_subscriptions:
unsubscribe: unsubscribe:
action: Да, отписаться action: Да, я хочу отписаться
complete: Подписка отменена complete: Подписка отменена
confirmation_html: Вы точно желаете отписаться от всех уведомления типа «%{type}», доставляемых из сервера Mastodon %{domain} на ваш адрес электронной почты %{email}? Вы всегда сможете подписаться снова в <a href="%{settings_path}">настройках e-mail уведомлений</a>. confirmation_html: Вы уверены в том, что хотите отписаться от всех %{type}, которые вы получаете на адрес %{email} для учётной записи на сервере Mastodon %{domain}? Вы всегда сможете подписаться снова в <a href="%{settings_path}">настройках уведомлений по электронной почте</a>.
resubscribe_html: Если вы отписались от рассылки по ошибке, вы можете повторно подписаться на рассылку в настройках <a href="%{settings_path}">настроек почтовых уведомлений</a>. emails:
success_html: Вы больше не будете получать %{type} для Mastodon на %{domain} на вашу электронную почту %{email}. notification_emails:
favourite: уведомлений о добавлении ваших постов в избранное
follow: уведомлений о новых подписчиках
follow_request: уведомлений о новых запросах на подписку
mention: уведомлений о новых упоминаниях
reblog: уведомлений о продвижении ваших постов
resubscribe_html: Если вы отписались по ошибке и хотите подписаться снова, перейдите на страницу <a href="%{settings_path}">настройки уведомлений по электронной почте</a>.
success_html: Вы отказались от %{type}, которые вы получали на адрес %{email} для вашей учётной записи на сервере Mastodon %{domain}.
title: Отписаться title: Отписаться
media_attachments: media_attachments:
validations: validations:
@ -1721,13 +1728,13 @@ ru:
trillion: трлн trillion: трлн
unit: '' unit: ''
otp_authentication: otp_authentication:
code_hint: Для подтверждения введите код, сгенерированный приложением-аутентификатором code_hint: Для подтверждения введите код из приложения-аутентификатора
description_html: Подключив <strong>двуфакторную авторизацию</strong>, для входа в свою учётную запись вам будет необходим смартфон и приложение-аутентификатор на нём, которое будет генерировать специальные временные коды. Без этих кодов войти в учётную запись не получиться, даже если все данные верны, что существенно увеличивает безопасность вашей учётной записи. description_html: Подключите <strong>двухфакторную аутентификацию</strong> с использованием специального приложения-аутентификатора, и тогда для входа в вашу учётную запись необходимо будет иметь при себе смартфон, который будет генерировать одноразовые коды.
enable: Включить enable: Включить
instructions_html: "<strong>Отсканируйте этот QR-код с помощью приложения-аутентификатора</strong>, такого как Google Authenticator, Яндекс.Ключ или andOTP. После сканирования и добавления, приложение начнёт генерировать коды, которые потребуется вводить для завершения входа в учётную запись." instructions_html: "<strong>Откройте Google Authenticator или другое приложение-аутентификатор на вашем смартфоне и отсканируйте этот QR-код</strong>. В дальнейшем это приложение будет генерировать одноразовые коды, которые потребуется вводить для подтверждения входа в вашу учётную запись."
manual_instructions: 'Если отсканировать QR-код не получается или не представляется возможным, вы можете ввести ключ настройки вручную:' manual_instructions: 'Если отсканировать QR-код не получается, введите секретный ключ вручную:'
setup: Настроить setup: Подключить
wrong_code: Введенный код недействителен! Время сервера и время устройства правильно? wrong_code: Одноразовый код, который вы ввели, не подходит! Совпадает ли время на устройстве с временем на сервере?
pagination: pagination:
newer: Позже newer: Позже
next: Вперёд next: Вперёд
@ -1751,14 +1758,14 @@ ru:
posting_defaults: Предустановки для новых постов posting_defaults: Предустановки для новых постов
public_timelines: Публичные ленты public_timelines: Публичные ленты
privacy: privacy:
hint_html: "<strong>Настройте, как вы хотите, чтобы ваш профиль и ваши сообщения были найдены.</strong> Различные функции в Mastodon могут помочь вам охватить более широкую аудиторию, если они включены. Уделите время изучению этих настроек, чтобы убедиться, что они подходят для вашего случая использования." hint_html: "<strong>Здесь вы можете определить то, как другие смогут обнаружить ваши посты и ваш профиль.</strong> Множество разных функций в Mastodon существуют для того, чтобы помочь вам выйти на более широкую аудиторию, если вы того захотите. Ознакомьтесь с этими настройками и в случае необходимости измените их согласно вашим желаниям."
privacy: Приватность privacy: Приватность
privacy_hint_html: Определите, какую информацию вы хотите раскрыть в интересах других. Люди находят интересные профили и приложения, просматривая список подписчиков других людей и узнавая, из каких приложений они публикуют свои сообщения, но вы можете предпочесть скрыть это. privacy_hint_html: Решите, сколько данных о себе вы готовы раскрыть ради того, чтобы они пошли на пользу другим. Просматривая ваши подписки, кто-то может обнаружить профили интересных людей, а ещё кто-нибудь может найти своё любимое приложение, увидев его название рядом с вашими постами. Тем не менее вы можете предпочесть не раскрывать эту информацию.
reach: Видимость reach: Видимость
reach_hint_html: Укажите, хотите ли вы, чтобы новые люди обнаруживали вас и могли следить за вами. Хотите ли вы, чтобы ваши сообщения появлялись на экране Обзора? Хотите ли вы, чтобы другие люди видели вас в своих рекомендациях? Хотите ли вы автоматически принимать всех новых подписчиков или иметь возможность детально контролировать каждого из них? reach_hint_html: Решите, нужна ли вам новая аудитория и новые подписчики. Настройте по своему желанию, показывать ли ваши посты в разделе «Актуальное», рекомендовать ли ваш профиль другим людям, принимать ли всех новых подписчиков автоматически или рассматривать каждый запрос на подписку в отдельности.
search: Поиск search: Поиск
search_hint_html: Определите, как вас могут найти. Хотите ли вы, чтобы люди находили вас по тому, о чём вы публично писали? Хотите ли вы, чтобы люди за пределами Mastodon находили ваш профиль при поиске в Интернете? Следует помнить, что полное исключение из всех поисковых систем не может быть гарантировано для публичной информации. search_hint_html: Определите, как вас могут найти. Хотите ли вы, чтобы люди находили вас по тому, о чём вы публично писали? Хотите ли вы, чтобы люди за пределами Mastodon находили ваш профиль при поиске в Интернете? Следует помнить, что полное исключение из всех поисковых систем не может быть гарантировано для публичной информации.
title: Приватность и доступ title: Приватность и видимость
privacy_policy: privacy_policy:
title: Политика конфиденциальности title: Политика конфиденциальности
reactions: reactions:

View File

@ -160,6 +160,10 @@ en:
name: Public name of the role, if role is set to be displayed as a badge 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... 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 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: webhook:
events: Select events to send events: Select events to send
template: Compose your own JSON payload using variable interpolation. Leave blank for default JSON. template: Compose your own JSON payload using variable interpolation. Leave blank for default JSON.
@ -371,6 +375,10 @@ en:
name: Name name: Name
permissions_as_keys: Permissions permissions_as_keys: Permissions
position: Priority position: Priority
username_block:
allow_with_approval: Allow registrations with approval
comparison: Method of comparison
username: Word to match
webhook: webhook:
events: Enabled events events: Enabled events
template: Payload template template: Payload template

View File

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

View File

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

View File

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

View File

@ -20,28 +20,6 @@ defaults: &defaults
trends: true trends: true
trends_as_landing_page: true trends_as_landing_page: true
trendable_by_default: false 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 disallowed_hashtags: # space separated string or list of hashtags without the hash
bootstrap_timeline_accounts: '' bootstrap_timeline_accounts: ''
activity_api_enabled: true 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" 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 t.datetime "updated_at", null: false
end 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| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.datetime "created_at", precision: nil, 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 it 'does not persist the job IDs' do
expect(subject.jobs).to eq [] expect(subject.jobs).to eq []
end 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 end
context 'when called with an array of job IDs' do context 'when called with an array of job IDs' do
@ -71,7 +63,7 @@ RSpec.describe WorkerBatch do
before do before do
subject.connect(async_refresh_key, threshold: 0.5) if async_refresh.present? subject.connect(async_refresh_key, threshold: 0.5) if async_refresh.present?
subject.add_jobs(%w(foo bar baz)) subject.add_jobs(%w(foo bar baz))
subject.remove_job('foo') subject.remove_job('foo', increment: true)
end end
it 'removes the job from pending jobs' do 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 attr_accessor :username
validates_with UnreservedUsernameValidator validates_with UnreservedUsernameValidator
def self.name
'Foo'
end
end end
end end
let(:record) { record_class.new } let(:record) { record_class.new }
describe '#validate' do describe '#validate' do
@ -114,7 +119,7 @@ RSpec.describe UnreservedUsernameValidator do
end end
def stub_reserved_usernames(value) 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 end
end end