Compare commits

...
Author SHA1 Message Date
HampusandGitHub 81d69c41f5 feat(discovery): resolve message links into discoverable guilds (#3159) 2026-10-03 13:12:30 +02:00
HampusandGitHub 4e6b837ccc fix: tighten edge cases across services (#3158) 2026-10-03 13:04:29 +02:00
HampusandGitHub a9f7a23c0d fix(voice): point the corner volume at the focused stream (#3157) 2026-10-03 12:37:24 +02:00
HampusandGitHub 07301adc6d fix(messages): keep mention highlight on hover in blocked groups (#3156) 2026-10-03 12:37:20 +02:00
HampusandGitHub c6630008b5 fix(voice): enlarge participant avatars in the voice panel (#3155) 2026-10-03 12:19:37 +02:00
HampusandGitHub cdcaba34ce fix(self-hosting): cap meilisearch indexing threads by default (#3153) 2026-10-03 02:15:16 +02:00
HampusandGitHub 09b9a57e38 fix(guild): match the verification discovery note to filtering (#3152) 2026-10-03 02:14:54 +02:00
HampusandGitHub 79d7c85832 fix(app): retry emoji picker images that fail to load (#3151) 2026-10-03 02:14:28 +02:00
HampusandGitHub eb0e8366bc fix(voice): keep saved linux audio apps in the source picker (#3150) 2026-10-03 02:14:06 +02:00
HampusandGitHub cf9752db4f fix(voice): release call modals when fullscreen ends (#3149) 2026-10-03 02:13:46 +02:00
HampusandGitHub d6fb3b2c50 fix(apps): stop bot permission labels overlapping (#3148) 2026-10-03 02:11:57 +02:00
HampusandGitHub b04fdc68df fix(storage): fall back when cross-bucket copy is rejected (#3147) 2026-10-03 02:11:34 +02:00
HampusandGitHub 706c41aad9 fix(auth): check the TOTP setup code before asking for sudo (#3146) 2026-10-03 02:11:14 +02:00
HampusandGitHub db9ec0605e fix(media-proxy): stop rejecting large storage transport chunks (#3144) 2026-10-03 02:10:55 +02:00
omsterandGitHub a95172bf88 fix(app-call): utilise popout window opened by manager (#2960) 2026-10-03 01:11:23 +02:00
HampusandGitHub 597116a0b4 fix(app): stop blurring reactions and stickers in CW channels (#3141) 2026-10-02 23:52:17 +02:00
HampusandGitHub 811341bc2f feat(email): configurable reply-to address (#3140) 2026-10-02 23:26:01 +02:00
HampusandGitHub 98fa41dcf0 fix(voice): avoid capped software h264 for auto screen shares (#3139) 2026-10-02 22:52:03 +02:00
HampusandGitHub b52a0b5d5f fix: friendlier wording for paused messaging (#3138) 2026-10-02 22:50:34 +02:00
339 changed files with 5287 additions and 2787 deletions
+7 -3
View File
@@ -309,6 +309,7 @@ FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
#[email protected]
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
@@ -411,7 +412,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=1536mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
@@ -437,8 +438,11 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# Meilisearch indexing memory and threads. Each indexing thread needs its own
# buffers on top of the indexing memory, so raise the threads only together with
# the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
#FLUXER_MEILISEARCH_MAX_INDEXING_THREADS=2
#FLUXER_MEILISEARCH_ENV=production
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
+4 -2
View File
@@ -113,6 +113,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
FLUXER_EMAIL_REPLY_TO_EMAIL: ${FLUXER_EMAIL_REPLY_TO_EMAIL:-}
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
@@ -329,12 +330,13 @@ services:
deploy:
resources:
limits:
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-1536mb}
environment:
MEILI_ENV: ${FLUXER_MEILISEARCH_ENV:-production}
MEILI_NO_ANALYTICS: "${FLUXER_MEILISEARCH_NO_ANALYTICS:-true}"
MEILI_UPGRADE_DB: "true"
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-256mb}
MEILI_MAX_INDEXING_THREADS: ${FLUXER_MEILISEARCH_MAX_INDEXING_THREADS:-2}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
+1 -1
View File
@@ -600,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec<Face> {
}
assert!(
face["unicodeRange"].is_null(),
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
"{wanted} face {} has a unicode-range; Latin-core faces must not",
face["file"]
);
faces.push(Face {
+8 -8
View File
@@ -280,7 +280,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
"description": "Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -1108,7 +1108,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
"description": "List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
"security": [{"adminApiKey": []}]
}
},
@@ -1529,7 +1529,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.",
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -4680,7 +4680,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -4715,10 +4715,10 @@
"in": "query",
"required": false,
"schema": {
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename",
"description": "Return the single message with this attachment together with its surrounding context; requires filename",
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
},
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename"
"description": "Return the single message with this attachment together with its surrounding context; requires filename"
},
{
"name": "filename",
@@ -12482,7 +12482,7 @@
},
"value_field": {
"type": "string",
"description": "The request body field that carries the entry value when adding to this blocklist"
"description": "The request body field that holds the entry value when adding to this blocklist"
},
"fields": {
"maxItems": 8,
@@ -13542,7 +13542,7 @@
"additionalProperties": false
},
"referenced_message": {
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
"nullable": true,
"type": "object",
"properties": {
+1 -1
View File
@@ -432,7 +432,7 @@ mod tests {
use serde_json::{Value, json};
#[test]
fn audit_log_reason_header_carries_utf8_bytes() {
fn audit_log_reason_header_keeps_utf8_bytes() {
let reason = "§ 3 Regel – wiederholt 日本";
let value = audit_log_reason_header(reason).expect("valid reason header");
assert_eq!(value.as_bytes(), reason.as_bytes());
+1 -1
View File
@@ -195,7 +195,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
.expect("form has a csrf token");
let response = app
.router
.clone()
@@ -163,7 +163,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
.expect("form has a csrf token");
let response = app
.router
.clone()
@@ -4,6 +4,7 @@ export interface EmailConfig {
enabled: boolean;
fromEmail: string;
fromName: string;
replyTo?: string | null;
appBaseUrl: string;
marketingBaseUrl: string;
}
@@ -14,6 +15,7 @@ export interface EmailMessage {
email: string;
name: string;
};
replyTo?: string;
subject: string;
text: string;
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {describe, expect, it} from 'vitest';
const CONFIG: EmailConfig = {
enabled: true,
fromEmail: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
marketingBaseUrl: 'https://example.com',
};
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
await expect(service.sendRegistrationApprovedEmail('[email protected]', 'testuser', 'en-US')).resolves.toBe(true);
expect(sent).toHaveLength(1);
return sent[0];
}
describe('EmailService reply-to', () => {
it('sets the configured reply-to address on every message', async () => {
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
expect(message.replyTo).toBe('[email protected]');
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
});
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
const message = await sendWith({...CONFIG, replyTo});
expect(message).not.toHaveProperty('replyTo');
});
});
@@ -375,6 +375,7 @@ export class EmailService implements IEmailService {
return this.provider.sendEmail({
to: email,
from: {email: this.config.fromEmail, name: this.config.fromName},
...(this.config.replyTo ? {replyTo: this.config.replyTo} : {}),
subject,
text: body,
});
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
vi.mock('nodemailer', () => ({
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
}));
const MESSAGE = {
to: '[email protected]',
from: {email: '[email protected]', name: 'Fluxer'},
subject: 'Subject',
text: 'Body',
};
function createProvider(): SmtpEmailProvider {
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
}
describe('SmtpEmailProvider', () => {
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue({});
});
it('passes the reply-to address to nodemailer', async () => {
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
expect(sendMail).toHaveBeenCalledWith({
to: '[email protected]',
from: 'Fluxer <[email protected]>',
replyTo: '[email protected]',
subject: 'Subject',
text: 'Body',
});
});
it('omits the reply-to address when the message has none', async () => {
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
});
});
@@ -45,6 +45,7 @@ export class SmtpEmailProvider implements IEmailProvider {
await this.transporter.sendMail({
to: message.to,
from: `${message.from.name} <${message.from.email}>`,
...(message.replyTo ? {replyTo: message.replyTo} : {}),
subject: message.subject,
text: message.text,
});
@@ -80,7 +80,7 @@ describe('reconstructOriginalUrl', () => {
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
it('does not double the question mark when the query segment has one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
+4 -4
View File
@@ -43,13 +43,13 @@ describe('buildAPIServerOptions', () => {
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator header timeout from the environment into the server', async () => {
test('passes the operator header timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
expect(server.headersTimeout).toBe(45_000);
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator request timeout from the environment into the server', async () => {
test('passes the operator request timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(600_000);
@@ -134,7 +134,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
master = await loadConfig();
});
it('carries the retired stripe price map from master config onto the api config', () => {
it('copies the retired stripe price map from master config onto the api config', () => {
const legacyPrices = {
monthly_brl: ['price_retired_monthly_brl'],
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
@@ -145,7 +145,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
);
});
it('carries the retired price map even when no live prices are configured', () => {
it('copies the retired price map even when no live prices are configured', () => {
const withoutPrices: MasterConfig = {
...master,
integrations: {
+1
View File
@@ -248,6 +248,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
fromEmail: master.integrations.email.from_email,
fromName: master.integrations.email.from_name,
replyToEmail: master.integrations.email.reply_to_email,
appBaseUrl: resolveEmailAppBaseUrl(master),
smtp: master.integrations.email.smtp
? {
@@ -146,7 +146,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
'Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
}),
async (ctx) => {
const adminApiKeyService = ctx.get('adminApiKeyService');
@@ -269,7 +269,7 @@ export function BanAdminController(app: HonoApp) {
security: ['adminApiKey'],
tags: ['Admin'],
description:
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
'List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
}),
async (ctx) => {
await recordAdminRead(ctx, {
@@ -524,7 +524,7 @@ export function BanAdminController(app: HonoApp) {
tags: ['Admin'],
requestSchema: AdminBlocklistEntryUpdateRequest,
description:
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.',
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -49,7 +49,7 @@ export function MessageAdminController(app: HonoApp) {
operationId: 'search_admin_messages',
summary: 'Search messages',
description:
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
responseSchema: AdminMessageSearchResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -159,7 +159,7 @@ describe('VoiceAdminController', () => {
expect(deletedRegion.success).toBe(true);
expect(await voiceRepository.getRegion(fixture.regionId)).toBeNull();
});
test('rejects voice server creation when no region carries the identifier', async () => {
test('rejects voice server creation when no region has the identifier', async () => {
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_SERVER_CREATE]);
const regionId = 'voice-region-missing-for-server-create';
const serverId = 'voice-server-missing-region';
+1 -1
View File
@@ -280,7 +280,7 @@ export async function resetPassword(
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
await users.deleteAllPasswordResetTokens(user.id);
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
}
+2 -5
View File
@@ -44,7 +44,6 @@ interface DispatchAuthSessionChangeParams {
userId: UserID;
oldAuthSessionIdHash: string;
newAuthSessionIdHash: string;
newToken: string;
}
interface ReplaceCurrentAuthSessionParams {
@@ -192,13 +191,12 @@ export async function replaceCurrentAuthSession(
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
await dispatchAuthSessionChange(ctx, {
userId: user.id,
oldAuthSessionIdHash,
newAuthSessionIdHash,
newToken,
});
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
return {
token: newToken,
authSession: newAuthSession,
@@ -231,14 +229,13 @@ function encodeSessionIdHash(sessionIdHash: Uint8Array): string {
async function dispatchAuthSessionChange(ctx: ApiContext, params: DispatchAuthSessionChangeParams): Promise<void> {
const {gateway} = ctx.services;
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params;
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash} = params;
await gateway.dispatchPresence({
userId,
event: 'AUTH_SESSION_CHANGE',
data: {
old_auth_session_id_hash: oldAuthSessionIdHash,
new_auth_session_id_hash: newAuthSessionIdHash,
new_token: newToken,
},
});
}
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
loginAccount,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {generateUniquePassword, HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface AuthSessionRow {
id_hash: string;
current: boolean;
}
interface ReplacementResponse {
token?: string;
auth_session_id_hash?: string;
}
type GatewayCall = {kind: 'terminate'; hashes: Array<string>} | {kind: 'session_change'; data: Record<string, unknown>};
async function getCurrentAuthSessionHash(harness: ApiTestHarness, token: string): Promise<string> {
const sessions = await createBuilder<Array<AuthSessionRow>>(harness, token).get('/auth/sessions').execute();
const current = sessions.find((session) => session.current);
if (!current) {
throw new Error('Current auth session not found');
}
return current.id_hash;
}
async function completePasswordChange(
harness: ApiTestHarness,
account: TestAccount,
newPassword: string,
): Promise<ReplacementResponse> {
const start = await createBuilder<{ticket: string}>(harness, account.token)
.post('/users/@me/password-change/start')
.body({})
.execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const record = findLastTestEmail(emails, 'password_change_verification');
if (!record) {
throw new Error('Password change verification email not found');
}
const verify = await createBuilder<{verification_proof: string}>(harness, account.token)
.post('/users/@me/password-change/verify')
.body({ticket: start.ticket, code: record.metadata.code})
.execute();
return createBuilder<ReplacementResponse>(harness, account.token)
.post('/users/@me/password-change/complete')
.body({ticket: start.ticket, verification_proof: verify.verification_proof, new_password: newPassword})
.expect(HTTP_STATUS.OK)
.execute();
}
describe('Auth session replacement on password change', () => {
let harness: ApiTestHarness;
let calls: Array<GatewayCall>;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
calls = [];
vi.spyOn(NoopGatewayService.prototype, 'terminateSession').mockImplementation(async (params) => {
calls.push({kind: 'terminate', hashes: [...params.sessionIdHashes]});
});
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence').mockImplementation(async (params) => {
if (params.event === 'AUTH_SESSION_CHANGE') {
calls.push({kind: 'session_change', data: params.data as Record<string, unknown>});
}
});
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
function expectReplacedSessionClosedBeforeEvent(oldHash: string, newHash: string | undefined): void {
const eventIndex = calls.findIndex((call) => call.kind === 'session_change');
const terminateIndex = calls.findIndex((call) => call.kind === 'terminate' && call.hashes.includes(oldHash));
expect(terminateIndex).toBeGreaterThanOrEqual(0);
expect(eventIndex).toBeGreaterThan(terminateIndex);
const event = calls[eventIndex] as Extract<GatewayCall, {kind: 'session_change'}>;
expect(event.data).toEqual({old_auth_session_id_hash: oldHash, new_auth_session_id_hash: newHash});
}
it('returns the replacement token from PATCH /users/@me', async () => {
const account = await createTestAccount(harness);
const otherSession = await loginAccount(harness, account);
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
calls = [];
const response = await createBuilder<ReplacementResponse>(harness, account.token)
.patch('/users/@me')
.body({password: account.password, new_password: generateUniquePassword()})
.expect(HTTP_STATUS.OK)
.execute();
expect(typeof response.token).toBe('string');
expect(typeof response.auth_session_id_hash).toBe('string');
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
await createBuilder(harness, otherSession.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
expect(await getCurrentAuthSessionHash(harness, response.token!)).toBe(response.auth_session_id_hash);
});
it('leaves the PATCH response without a token when the password is unchanged', async () => {
const account = await createTestAccount(harness);
const response = await createBuilder<ReplacementResponse>(harness, account.token)
.patch('/users/@me')
.body({global_name: 'Renamed'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.token).toBeUndefined();
expect(response.auth_session_id_hash).toBeUndefined();
expect(calls).toEqual([]);
});
it('closes the replaced session before announcing the change on password-change/complete', async () => {
const account = await createTestAccount(harness);
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
calls = [];
const response = await completePasswordChange(harness, account, generateUniquePassword());
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
});
});
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, createTotpSecret, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface ValidationErrorBody {
code: string;
errors: Array<{path: string; code: string}>;
}
function wrongCodeFor(code: string): string {
return ((Number(code) + 500_000) % 1_000_000).toString().padStart(6, '0');
}
describe('Enabling TOTP checks the setup code before sudo', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects a wrong setup code on the code field without asking for a password', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: wrongCodeFor(totpCodeNow(secret))})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_CODE);
});
it('asks for sudo for a valid setup code, then enables with the password', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
const enabled = await createBuilder<{backup_codes: Array<{code: string}>}>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code, password: account.password})
.expect(HTTP_STATUS.OK)
.execute();
expect(enabled.backup_codes.length).toBeGreaterThan(0);
});
});
@@ -65,6 +65,45 @@ describe('Password reset flow', () => {
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('invalidates every outstanding reset token once a reset completes', async () => {
const account = await createTestAccount(harness);
for (let i = 0; i < 2; i++) {
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({email: account.email})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
const emails = await listTestEmails(harness, {recipient: account.email});
const tokens = [
...new Set(
emails
.filter((email) => email.type === 'password_reset')
.map((email) => email.metadata?.token)
.filter((token): token is string => typeof token === 'string'),
),
];
expect(tokens).toHaveLength(2);
const [earlierToken, laterToken] = tokens;
const newPassword = generateUniquePassword();
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/reset')
.body({token: laterToken, password: newPassword})
.execute();
expect(resetResp.token.length).toBeGreaterThan(0);
const check = await createBuilderWithoutAuth<{valid: boolean}>(harness)
.get(`/auth/reset/${earlierToken}`)
.execute();
expect(check.valid).toBe(false);
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token: earlierToken, password: generateUniquePassword()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await createBuilder(harness, resetResp.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
const login = await loginUser(harness, {email: account.email, password: newPassword});
expect('token' in login && login.token.length > 0).toBe(true);
});
it('rejects invalid reset token', async () => {
await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
@@ -22,7 +22,7 @@ describe('WebAuthn registration user handle', () => {
afterAll(async () => {
await harness?.shutdown();
});
it('ensures registration options carry the stable user identifier', async () => {
it('ensures registration options include the stable user identifier', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
@@ -500,7 +500,7 @@ describe('mapStripeRefundToRow', () => {
expect(result.byPaymentIntent).not.toBeNull();
expect(result.byInvoice).toBeNull();
});
it('payment_intent is an expanded object; hints carry through', () => {
it('payment_intent is an expanded object; hints pass through', () => {
const r = stripeFixture<Stripe.Refund>({
id: 're_2',
charge: null,
@@ -172,6 +172,8 @@ export class ChannelService {
snowflakeService,
this.messages.persistence,
limitConfigService,
voiceRoomStore,
liveKitService,
);
this.calls = new CallService(
channelRepository,
@@ -72,7 +72,7 @@ describe('StreamService.uploadPreview', () => {
expect(uploaded).toHaveLength(0);
});
it('rejects a thumbnail carrying no base64 digits', async () => {
it('rejects a thumbnail with no base64 digits', async () => {
await expect(upload('====')).rejects.toBeInstanceOf(InvalidStreamThumbnailPayloadError);
expect(uploaded).toHaveLength(0);
});
@@ -14,6 +14,7 @@ import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
@@ -629,6 +630,27 @@ export class ChannelOperationsService {
}
}
private async checkOverwritePermission(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
}): Promise<void> {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: params.guildId,
userId: params.userId,
channelId: params.channelId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(Permissions.MANAGE_ROLES);
}
async setChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
@@ -644,13 +666,7 @@ export class ChannelOperationsService {
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
@@ -716,13 +732,7 @@ export class ChannelOperationsService {
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
const removedRole = overwrites.get(createRoleID(params.overwriteId));
@@ -10,8 +10,11 @@ import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
@@ -47,6 +50,8 @@ export class GroupDmOperationsService {
private snowflakeService: ISnowflakeService,
private messagePersistenceService: MessagePersistenceService,
private readonly limitConfigService: LimitConfigService,
private readonly voiceRoomStore: IVoiceRoomStore,
private readonly liveKitService: ILiveKitService,
) {
this.userPermissionUtils = new UserPermissionUtils(userRepository, guildRepository);
}
@@ -258,6 +263,7 @@ export class GroupDmOperationsService {
await deleteChannelMessageSearchDocuments(channelId, {context: {source: 'group_dm_delete'}});
await this.channelRepository.channelData.delete(channelId);
await this.userRepository.closeDmForUser(recipientId, channelId);
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
await dispatchChannelDelete({
channel,
requestCache,
@@ -275,6 +281,7 @@ export class GroupDmOperationsService {
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
});
await this.userRepository.closeDmForUser(recipientId, channelId);
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
for (const recId of updatedRecipientIds) {
await this.gatewayService.dispatchPresence({
@@ -319,6 +326,31 @@ export class GroupDmOperationsService {
);
}
private async disconnectRemovedRecipientFromCall(channelId: ChannelID, recipientId: UserID): Promise<void> {
try {
const {voiceStates} = await this.gatewayService.getVoiceStatesForChannel({channelId});
await this.gatewayService.disconnectVoiceUserIfInChannel({channelId, userId: recipientId});
const recipientVoiceStates = voiceStates.filter((voiceState) => voiceState.userId === recipientId.toString());
if (recipientVoiceStates.length === 0) return;
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(undefined, channelId);
if (!pinnedServer) return;
for (const voiceState of recipientVoiceStates) {
await this.liveKitService.disconnectParticipant({
userId: recipientId,
channelId,
connectionId: voiceState.connectionId,
regionId: pinnedServer.regionId,
serverId: pinnedServer.serverId,
});
}
} catch (error) {
Logger.error(
{error, channelId: channelId.toString(), userId: recipientId.toString()},
'Failed to disconnect removed group DM recipient from call',
);
}
}
private async syncGroupDmRecipientsForUser(userId: UserID): Promise<void> {
const channels = await this.userRepository.listPrivateChannels(userId);
const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM);
@@ -100,7 +100,7 @@ describe('Attachment Upload Validation', () => {
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
}),
method: 'POST',
path: `/channels/${channelId}/messages`,
path: '/channels/:channel_id/messages',
requestId: expect.any(String),
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
},
@@ -471,7 +471,7 @@ describe('Crosspost moderation', () => {
});
}
test('forwarding a published source carries none of the server bits', async () => {
test('forwarding a published source keeps none of the server bits', async () => {
const source = await sendChannelMessage(harness, world.b.owner.token, world.a.ann.id, 'forward me');
await publish(harness, world.b.owner.token, world.a.ann.id, source.id);
const forwarded = await forward(world.b.owner.token, world.b.t2.id, world.a.ann.id, world.a.guild.id, source.id)
@@ -480,7 +480,7 @@ describe('Crosspost moderation', () => {
expect(forwarded.message_snapshots?.[0]?.flags ?? 0).toBe(0);
});
test('forwarding a copy carries none of the server bits', async () => {
test('forwarding a copy keeps none of the server bits', async () => {
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
const {copyId} = await fabricateCopy({
harness,
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createFriendship,
createGroupDmChannel,
getChannel,
removeRecipientFromGroupDm,
} from '@app/api/channel/tests/ChannelTestUtils';
import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitService';
import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('Group DM recipient removal call teardown', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupGroupDm() {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const other = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
await ensureSessionStarted(harness, other.token);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, other);
const groupDm = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
return {owner, member, other, groupDm};
}
it('disconnects the removed recipient from the call and the voice room', async () => {
const {owner, member, other, groupDm} = await setupGroupDm();
vi.spyOn(NoopGatewayService.prototype, 'getVoiceStatesForChannel').mockResolvedValue({
voiceStates: [
{connectionId: 'member-conn', userId: member.userId, channelId: groupDm.id},
{connectionId: 'other-conn', userId: other.userId, channelId: groupDm.id},
],
});
vi.spyOn(InMemoryVoiceRoomStore.prototype, 'getPinnedRoomServer').mockResolvedValue({
regionId: 'region-a',
serverId: 'server-a',
endpoint: 'wss://voice.invalid',
});
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
const disconnectParticipant = vi.spyOn(DisabledLiveKitService.prototype, 'disconnectParticipant');
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
const callParams = disconnectFromCall.mock.calls[0]![0];
expect(callParams.guildId).toBeUndefined();
expect(callParams.channelId.toString()).toBe(groupDm.id);
expect(callParams.userId.toString()).toBe(member.userId);
expect(disconnectParticipant).toHaveBeenCalledTimes(1);
const participantParams = disconnectParticipant.mock.calls[0]![0];
expect(participantParams.userId.toString()).toBe(member.userId);
expect(participantParams.channelId.toString()).toBe(groupDm.id);
expect(participantParams.connectionId).toBe('member-conn');
expect(participantParams.regionId).toBe('region-a');
expect(participantParams.serverId).toBe('server-a');
});
it('disconnects a recipient who leaves the group DM themselves', async () => {
const {member, groupDm} = await setupGroupDm();
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
await removeRecipientFromGroupDm(harness, member.token, groupDm.id, member.userId);
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
expect(disconnectFromCall.mock.calls[0]![0].userId.toString()).toBe(member.userId);
});
it('still removes the recipient when the call teardown fails', async () => {
const {owner, member, groupDm} = await setupGroupDm();
vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel').mockRejectedValue(
new Error('gateway unavailable'),
);
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
const channel = await getChannel(harness, owner.token, groupDm.id);
expect(channel.recipients?.map((recipient) => recipient.id)).not.toContain(member.userId);
});
});
@@ -249,7 +249,7 @@ describe('Crosspost fan-out', () => {
expect(after?.mentionedRoleIds.size).toBe(0);
});
test('sendable flags carry over to the copy', async () => {
test('the copy keeps the sendable flags', async () => {
await followInto(harness, world, world.b.t1.id);
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'quiet'});
const sendable = MessageFlags.SUPPRESS_EMBEDS | MessageFlags.SUPPRESS_NOTIFICATIONS | MessageFlags.VOICE_MESSAGE;
@@ -259,7 +259,7 @@ describe('Crosspost fan-out', () => {
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | sendable);
});
test('copies carry the source attachments and resolve to the source channel', async () => {
test('copies have the source attachments and resolve to the source channel', async () => {
await followInto(harness, world, world.b.t1.id);
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'files'}, [
'first.png',
@@ -42,7 +42,7 @@ describe('Reaction users pagination', () => {
return {token: owner.token, channelId: systemChannel.id, messageId: message.id};
}
it('carries the pagination signal of the page in headers', async () => {
it('sends the pagination signal of the page in headers', async () => {
const {token, channelId, messageId} = await setupReactedMessage();
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
+1
View File
@@ -155,6 +155,7 @@ export interface APIConfig {
webhookSecret?: string;
fromEmail: string;
fromName: string;
replyToEmail: string;
appBaseUrl: string;
smtp?: {
host: string;
@@ -67,7 +67,7 @@ function collectErrorChain(error: unknown): Array<ErrorNode> {
return nodes;
}
function carriesPostgresClient(node: ErrorNode): boolean {
function hasPostgresClient(node: ErrorNode): boolean {
const client = node['client'];
return typeof client === 'object' && client !== null;
}
@@ -92,7 +92,7 @@ export function isTransientDatabaseError(error: unknown): boolean {
if (nodes.some(hasTransientSqlState)) {
return true;
}
if (nodes.some(carriesPostgresClient) && nodes.some(hasTransientSocketCode)) {
if (nodes.some(hasPostgresClient) && nodes.some(hasTransientSocketCode)) {
return true;
}
return nodes.some(hasTransientDriverMessage);
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID} from '@app/api/BrandedTypes';
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
@@ -15,12 +15,13 @@ import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstan
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildIdChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
DiscoveryApplicationPatchRequest,
DiscoveryApplicationRequest,
DiscoveryApplicationResponse,
DiscoveryCategoryListResponse,
DiscoveryChannelPreviewResponse,
DiscoveryGuildListResponse,
DiscoverySearchQuery,
DiscoveryStatusResponse,
@@ -100,6 +101,31 @@ export function GuildDiscoveryController(app: HonoApp) {
return ctx.json(categories);
},
);
app.get(
'/discovery/guilds/:guild_id/channels/:channel_id',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_CHANNEL_PREVIEW),
LoginRequired,
DefaultUserOnly,
Validator('param', GuildIdChannelIdParam),
OpenAPI({
operationId: 'get_discovery_channel_preview',
summary: 'Preview a channel in a discoverable guild',
description:
'Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.',
responseSchema: DiscoveryChannelPreviewResponse,
statusCode: 200,
security: ['sessionToken', 'bearerToken'],
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const {guild_id, channel_id} = ctx.req.valid('param');
const preview = await ctx
.get('discoveryService')
.getChannelPreview(createGuildID(guild_id), createChannelID(channel_id));
return ctx.json(preview);
},
);
app.post(
'/discovery/guilds/:guild_id/join',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {type ChannelID, type GuildID, guildIdToRoleId, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
@@ -10,6 +11,7 @@ import {contentModerationService} from '@app/api/infrastructure/ContentModeratio
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
DISCOVERY_DEFAULT_LANGUAGE,
DISCOVERY_MAX_TAGS,
@@ -83,6 +85,8 @@ export abstract class IGuildDiscoveryService {
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview>;
abstract searchDiscoverable(params: {
query?: string;
categoryId?: number;
@@ -118,7 +122,13 @@ interface DiscoveryGuildResult {
verification_level: number;
}
interface DiscoveryChannelPreview {
guild: {id: string; name: string; icon: string | null};
channel: {id: string; name: string | null; type: number};
}
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
const PUBLIC_CHANNEL_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
function toDiscoveryCategoryCounts(
counts: Readonly<Record<string, number>> | undefined,
@@ -143,6 +153,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly gatewayService: IGatewayService,
private readonly guildSearchService: IGuildSearchService | null,
private readonly channelDataRepository: IChannelDataRepository,
) {
super();
}
@@ -382,6 +393,38 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
return this.discoveryRepository.listFullByStatus(params.status);
}
async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview> {
const [status, guild, channel, everyoneRole] = await Promise.all([
this.discoveryRepository.findByGuildId(guildId),
this.guildRepository.findUnique(guildId),
this.channelDataRepository.findUnique(channelId),
this.guildRepository.getRole(guildIdToRoleId(guildId), guildId),
]);
if (
status?.status !== DiscoveryApplicationStatus.APPROVED ||
!guild ||
guild.features.has(GuildFeatures.INVITES_DISABLED) ||
!channel ||
channel.guildId !== guildId ||
!everyoneRole
) {
throw new DiscoveryNotDiscoverableError();
}
if ((everyoneRole.permissions & Permissions.ADMINISTRATOR) === 0n) {
const overwrite = channel.permissionOverwrites.get(everyoneRole.id);
const permissions = overwrite
? (everyoneRole.permissions & ~overwrite.deny) | overwrite.allow
: everyoneRole.permissions;
if ((permissions & PUBLIC_CHANNEL_PERMISSIONS) !== PUBLIC_CHANNEL_PERMISSIONS) {
throw new DiscoveryNotDiscoverableError();
}
}
return {
guild: {id: guild.id.toString(), name: guild.name, icon: guild.iconHash},
channel: {id: channel.id.toString(), name: channel.name, type: channel.type},
};
}
async searchDiscoverable(params: {
query?: string;
categoryId?: number;
@@ -54,6 +54,7 @@ import {
MAX_GUILD_ROLES,
VOICE_CHANNEL_BITRATE_DEFAULT,
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
VOICE_CHANNEL_USER_LIMIT_MAX,
} from '@fluxer/constants/src/LimitConstants';
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -1065,7 +1066,7 @@ export class GuildOperationsService {
content_warning_text: null,
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
user_limit: isVoice ? Math.min(channel.user_limit ?? 0, VOICE_CHANNEL_USER_LIMIT_MAX) : null,
voice_connection_limit: isVoice
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
: null,
@@ -3,7 +3,8 @@
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import type {GuildID} from '@app/api/BrandedTypes';
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils';
import {createChannel, createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
import {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry';
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
import {banUser} from '@app/api/moderation/tests/ModerationTestUtils';
@@ -15,11 +16,13 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
import type {
DiscoveryApplicationResponse,
DiscoveryCategoryResponse,
DiscoveryChannelPreviewResponse,
DiscoveryGuildListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
@@ -507,4 +510,65 @@ describe('Discovery Search and Join', () => {
.execute();
});
});
describe('channel preview', () => {
async function createListedGuild(name: string): Promise<{ownerToken: string; guildId: string; channelId: string}> {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, name);
await setGuildMemberCount(harness, guild.id, 10);
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
`${name} welcomes everyone`,
DiscoveryCategories.GAMING,
);
return {ownerToken: owner.token, guildId: guild.id, channelId: guild.system_channel_id!};
}
test('should preview a channel that new members can read', async () => {
const {guildId, channelId} = await createListedGuild('Preview Guild');
const viewer = await createTestAccount(harness);
const preview = await createBuilder<DiscoveryChannelPreviewResponse>(harness, viewer.token)
.get(`/discovery/guilds/${guildId}/channels/${channelId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(preview.guild.id).toBe(guildId);
expect(preview.guild.name).toBe('Preview Guild');
expect(preview.channel.id).toBe(channelId);
});
test('should not preview a channel hidden from everyone', async () => {
const {ownerToken, guildId} = await createListedGuild('Hidden Channel Guild');
const hidden = await createChannel(harness, ownerToken, guildId, 'staff');
await createPermissionOverwrite(harness, ownerToken, hidden.id, guildId, {
type: 0,
allow: '0',
deny: Permissions.VIEW_CHANNEL.toString(),
});
const viewer = await createTestAccount(harness);
await createBuilder(harness, viewer.token)
.get(`/discovery/guilds/${guildId}/channels/${hidden.id}`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
.execute();
});
test('should not preview a channel from another guild', async () => {
const {guildId} = await createListedGuild('Listed Guild');
const other = await createListedGuild('Other Listed Guild');
const viewer = await createTestAccount(harness);
await createBuilder(harness, viewer.token)
.get(`/discovery/guilds/${guildId}/channels/${other.channelId}`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
.execute();
});
test('should not preview a guild that is not listed', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Unlisted Guild');
const viewer = await createTestAccount(harness);
await createBuilder(harness, viewer.token)
.get(`/discovery/guilds/${guild.id}/channels/${guild.system_channel_id}`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
.execute();
});
});
});
@@ -117,7 +117,7 @@ describe('Guild expression clone opt-in', () => {
expect(cloned.name).toBe(source.sticker.name);
}
test('rejects both emoji and sticker cloning when the source guild carries no clone features', async () => {
test('rejects both emoji and sticker cloning when the source guild has no clone features', async () => {
const source = await createSource(harness, 'No Clone Features Source');
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
@@ -154,7 +154,7 @@ describe('Guild expression clone opt-in', () => {
await expectStickerCloneAllowed(source, 'Deprecated Plus Enabled');
});
test('rejects cloning when the source guild carries only the deprecated disabled features', async () => {
test('rejects cloning when the source guild has only the deprecated disabled features', async () => {
const source = await createSource(harness, 'Deprecated Only Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
@@ -186,7 +186,7 @@ describe('Guild expression clone opt-in', () => {
expect(stickerAfter.allow_cloning).toBe(true);
});
test('reports allow_cloning false for a guild carrying only the deprecated disabled features', async () => {
test('reports allow_cloning false for a guild with only the deprecated disabled features', async () => {
const source = await createSource(harness, 'Metadata Deprecated Source');
await addDeprecatedFeatures(harness, source, [
GuildFeatures.CLONE_EMOJI_DISABLED,
@@ -1,10 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
import {createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {
addMemberRole,
createGuild,
createRole,
getChannel,
setupTestGuildWithMembers,
} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -116,6 +123,43 @@ describe('Guild MFA level', () => {
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('requires 2FA for channel permission overwrite edits in an elevated guild', async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
const channel = channels[0]!;
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Managers',
permissions: (Permissions.MANAGE_ROLES | Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
});
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Target'});
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
await enableTotp(harness, owner);
const loggedInOwner = await loginWithTotp(harness, owner);
await createBuilder<GuildResponse>(harness, loggedInOwner.token)
.patch(`/guilds/${guild.id}`)
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
.expect(HTTP_STATUS.OK)
.execute();
const overwrite = {type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'};
await createBuilder(harness, member.token)
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
.body(overwrite)
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
.execute();
await createBuilder(harness, loggedInOwner.token)
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
.body(overwrite)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, member.token)
.delete(`/channels/${channel.id}/permissions/${targetRole.id}`)
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
.execute();
const stored = await getChannel(harness, loggedInOwner.token, channel.id);
expect(stored.permission_overwrites?.find((entry) => entry.id === targetRole.id)?.allow).toBe(
Permissions.SEND_MESSAGES.toString(),
);
});
it('does not require sudo mode for non-mfa_level guild updates', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
@@ -6,6 +6,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
import {VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
@@ -209,6 +210,84 @@ describe('Guild Template Import', () => {
expect(roles.some((role) => role.name === '')).toBe(true);
expect(channels.some((channel) => channel.name === '')).toBe(true);
});
test.each([
['a negative slowmode', {rate_limit_per_user: -1}],
['a slowmode above the channel maximum', {rate_limit_per_user: 1_000_000_000}],
['a fractional position', {position: 0.5}],
['a negative position', {position: -3}],
['a topic above the channel maximum', {topic: 'x'.repeat(1025)}],
['a name above the channel maximum', {name: 'x'.repeat(101)}],
['a negative user limit', {type: ChannelTypes.GUILD_VOICE, user_limit: -1}],
['a voice connection limit above the maximum', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: 100_000}],
['a negative voice connection limit', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: -5}],
])('rejects a template channel with %s', async (_label, overrides) => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/guilds')
.body({
name: 'Bounded Guild',
template: buildMinimalTemplate({
channels: [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, ...overrides}],
}),
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
test.each([
['a negative colour', {color: -1}],
['a colour above 0xffffff', {color: 0x1000000}],
['a name above the role maximum', {name: 'x'.repeat(101)}],
])('rejects a template role with %s', async (_label, overrides) => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/guilds')
.body({
name: 'Bounded Guild',
template: buildMinimalTemplate({
roles: [
{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS},
{id: 6100, name: 'Role', permissions: '0', ...overrides},
],
}),
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
test('clamps imported voice user limits to the channel maximum and keeps channels readable', async () => {
const account = await createTestAccount(harness);
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
.body({
name: 'Stage Guild',
template: buildMinimalTemplate({
channels: [
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, rate_limit_per_user: 30},
{id: 6002, type: 13, name: 'town-hall', position: 1, user_limit: 10_000},
{id: 6003, type: ChannelTypes.GUILD_VOICE, name: 'lounge', position: 2, voice_connection_limit: 100},
],
}),
})
.execute();
const channels = await getGuildChannels(harness, account.token, guild.id);
expect(channels.find((channel) => channel.name === 'general')?.rate_limit_per_user).toBe(30);
expect(channels.find((channel) => channel.name === 'town-hall')?.user_limit).toBe(VOICE_CHANNEL_USER_LIMIT_MAX);
expect(channels.find((channel) => channel.name === 'lounge')?.voice_connection_limit).toBe(100);
});
});
const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString();
function buildMinimalTemplate(overrides: {channels?: Array<object>; roles?: Array<object>}) {
return {
name: 'Template Source',
description: null,
verification_level: 0,
default_message_notifications: 0,
explicit_content_filter: 0,
system_channel_id: 6001,
afk_timeout: 300,
system_channel_flags: 0,
roles: overrides.roles ?? [{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS}],
channels: overrides.channels ?? [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}],
};
}
@@ -82,7 +82,7 @@ describe('AvatarService emoji and sticker size ceilings', () => {
{path: 'image', code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, variables: {maxSize: 1024}},
]);
});
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that carries the feature', async () => {
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that has the feature', async () => {
const rules: Array<LimitRule> = [
{id: 'big-emoji', filters: {guildFeatures: ['BIG_EMOJI']}, limits: {emoji_max_size: EMOJI_MAX_SIZE * 2}},
];
@@ -51,7 +51,7 @@ describe('canonicalizePurgeUrl', () => {
]);
});
it('keeps a base path when the media endpoint carries one', () => {
it('keeps a base path when the media endpoint has one', () => {
Config.endpoints.media = `${MEDIA}/media`;
expect(canonicalizePurgeUrl(`${MEDIA}/media/avatars/1/b35cc3d3`)).toEqual([
'media.test/media/avatars/1/b35cc3d3',
@@ -108,6 +108,22 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
return wrapped;
}
const REJECTED_SERVER_SIDE_COPY_ERRORS = new Set([
'NoSuchKey',
'NotFound',
'NotImplemented',
'AccessDenied',
'InvalidRequest',
'MethodNotAllowed',
]);
function isRejectedServerSideCopy(error: unknown): boolean {
return (
error instanceof S3ServiceException &&
(REJECTED_SERVER_SIDE_COPY_ERRORS.has(error.name) || error.$metadata?.httpStatusCode === 501)
);
}
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
@@ -473,15 +489,76 @@ export class StorageService implements IStorageService {
if (isSameObject && !newContentType) {
return;
}
await this.client.send(
new CopyObjectCommand({
try {
await this.client.send(
new CopyObjectCommand({
Bucket: destinationBucket,
Key: destinationKey,
CopySource: `${encodeURIComponent(sourceBucket)}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`,
ContentType: newContentType,
MetadataDirective: newContentType ? 'REPLACE' : undefined,
}),
);
} catch (copyError) {
if (sourceBucket === destinationBucket || !isRejectedServerSideCopy(copyError)) {
throw copyError;
}
await this.copyObjectThroughApi(
{sourceBucket, sourceKey, destinationBucket, destinationKey, newContentType},
copyError,
);
}
}
private async copyObjectThroughApi(
{
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
},
copyError: unknown,
): Promise<void> {
const source = await this.streamObject({bucket: sourceBucket, key: sourceKey});
if (!source) {
throw copyError;
}
Logger.warn(
{sourceBucket, destinationBucket, error: copyError},
'Object storage rejected a cross-bucket copy, copying through the API instead',
);
const upload = new Upload({
client: this.client,
params: {
Bucket: destinationBucket,
Key: destinationKey,
CopySource: `${encodeURIComponent(sourceBucket)}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`,
ContentType: newContentType,
MetadataDirective: newContentType ? 'REPLACE' : undefined,
}),
);
Body: source.body,
ContentType: newContentType ?? source.contentType ?? undefined,
...(newContentType
? {}
: {
CacheControl: source.cacheControl ?? undefined,
ContentDisposition: source.contentDisposition ?? undefined,
Expires: source.expires ?? undefined,
}),
},
partSize: STREAM_UPLOAD_PART_BYTES,
queueSize: STREAM_UPLOAD_CONCURRENCY,
leavePartsOnError: false,
});
try {
await upload.done();
} catch (error) {
source.body.destroy();
throw error;
}
}
async copyObjectWithMetadataStripping({
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {StorageService} from '@app/api/infrastructure/StorageService';
import {server} from '@app/api/test/msw/server';
import {HttpResponse, http} from 'msw';
import {beforeEach, describe, expect, it} from 'vitest';
const ENDPOINT = 'https://objects.ceph-rgw.test';
const UPLOADS = 'fluxer-uploads';
const CDN = 'fluxer-cdn';
interface StoredObject {
body: Uint8Array;
contentType: string;
cacheControl?: string;
contentDisposition?: string;
}
const NO_SUCH_KEY = (bucket: string) =>
new HttpResponse(
`<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message></Message><BucketName>${bucket}</BucketName><RequestId>tx0</RequestId><HostId>ceph</HostId></Error>`,
{status: 404, headers: {'Content-Type': 'application/xml'}},
);
function cephWithoutCrossBucketCopy() {
const objects = new Map<string, StoredObject>();
const copies: Array<{source: string; destination: string}> = [];
const locate = (params: {bucket?: string | ReadonlyArray<string>; key?: string | ReadonlyArray<string>}) => {
const bucket = String(params.bucket);
const key = Array.isArray(params.key) ? params.key.join('/') : String(params.key);
return {bucket, key, id: `${bucket}/${key}`};
};
server.use(
http.put(`${ENDPOINT}/:bucket/*`, async ({request, params}) => {
const target = locate({bucket: params.bucket, key: params[0] as string});
const copySource = request.headers.get('x-amz-copy-source');
if (copySource) {
const decoded = decodeURIComponent(copySource.replace(/^\//u, ''));
const sourceBucket = decoded.split('/')[0];
copies.push({source: decoded, destination: target.id});
if (sourceBucket !== target.bucket) {
return NO_SUCH_KEY(target.bucket);
}
const source = objects.get(decoded);
if (!source) {
return NO_SUCH_KEY(target.bucket);
}
objects.set(target.id, {
...source,
contentType: request.headers.get('content-type') ?? source.contentType,
});
return new HttpResponse(
'<?xml version="1.0" encoding="UTF-8"?><CopyObjectResult><ETag>"x"</ETag></CopyObjectResult>',
{status: 200, headers: {'Content-Type': 'application/xml'}},
);
}
const body = new Uint8Array(await request.arrayBuffer());
objects.set(target.id, {
body,
contentType: request.headers.get('content-type') ?? 'application/octet-stream',
...(request.headers.get('cache-control') ? {cacheControl: request.headers.get('cache-control')!} : {}),
...(request.headers.get('content-disposition')
? {contentDisposition: request.headers.get('content-disposition')!}
: {}),
});
return new HttpResponse(null, {status: 200, headers: {ETag: '"x"'}});
}),
http.get(`${ENDPOINT}/:bucket/*`, ({params}) => {
const target = locate({bucket: params.bucket, key: params[0] as string});
const object = objects.get(target.id);
if (!object) {
return NO_SUCH_KEY(target.bucket);
}
return new HttpResponse(object.body, {
status: 200,
headers: {
'Content-Type': object.contentType,
'Content-Length': String(object.body.length),
...(object.cacheControl ? {'Cache-Control': object.cacheControl} : {}),
...(object.contentDisposition ? {'Content-Disposition': object.contentDisposition} : {}),
},
});
}),
http.head(`${ENDPOINT}/:bucket/*`, ({params}) => {
const target = locate({bucket: params.bucket, key: params[0] as string});
const object = objects.get(target.id);
if (!object) {
return new HttpResponse(null, {status: 404});
}
return new HttpResponse(null, {
status: 200,
headers: {'Content-Type': object.contentType, 'Content-Length': String(object.body.length)},
});
}),
);
return {objects, copies};
}
function storage(): StorageService {
return new StorageService({
endpoint: ENDPOINT,
forcePathStyle: true,
region: 'nbg1',
accessKeyId: 'TEST',
secretAccessKey: 'TEST',
});
}
const PDF = new TextEncoder().encode('%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
describe('StorageService copies on providers that reject cross-bucket CopyObject', () => {
let ceph: ReturnType<typeof cephWithoutCrossBucketCopy>;
beforeEach(() => {
ceph = cephWithoutCrossBucketCopy();
});
it('stores a non-media attachment in the CDN bucket', async () => {
ceph.objects.set(`${UPLOADS}/upload-1`, {body: PDF, contentType: 'application/octet-stream'});
await expect(
storage().copyObjectWithMetadataStripping({
sourceBucket: UPLOADS,
sourceKey: 'upload-1',
destinationBucket: CDN,
destinationKey: 'attachments/1/2/file.pdf',
contentType: 'application/pdf',
}),
).resolves.toBeNull();
const stored = ceph.objects.get(`${CDN}/attachments/1/2/file.pdf`);
expect(stored?.contentType).toBe('application/pdf');
expect(Buffer.from(stored!.body).equals(Buffer.from(PDF))).toBe(true);
});
it('keeps the original file when media processing fails', async () => {
const brokenHeic = new Uint8Array(4096).fill(7);
ceph.objects.set(`${UPLOADS}/upload-2`, {body: brokenHeic, contentType: 'application/octet-stream'});
await expect(
storage().copyObjectWithMetadataStripping({
sourceBucket: UPLOADS,
sourceKey: 'upload-2',
destinationBucket: CDN,
destinationKey: 'attachments/1/3/photo.heic',
contentType: 'image/heic',
}),
).resolves.toBeNull();
const stored = ceph.objects.get(`${CDN}/attachments/1/3/photo.heic`);
expect(stored?.contentType).toBe('image/heic');
expect(Buffer.from(stored!.body).equals(Buffer.from(brokenHeic))).toBe(true);
});
it('keeps the source headers when no new content type is given', async () => {
ceph.objects.set(`${CDN}/avatars/1/a.png`, {
body: PDF,
contentType: 'image/png',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'inline',
});
await storage().copyObject({
sourceBucket: CDN,
sourceKey: 'avatars/1/a.png',
destinationBucket: 'fluxer-reports',
destinationKey: 'evidence/a.png',
});
expect(ceph.objects.get('fluxer-reports/evidence/a.png')).toMatchObject({
contentType: 'image/png',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'inline',
});
});
it('still fails when the source object does not exist', async () => {
await expect(
storage().copyObject({
sourceBucket: UPLOADS,
sourceKey: 'missing',
destinationBucket: CDN,
destinationKey: 'attachments/1/4/missing.pdf',
newContentType: 'application/pdf',
}),
).rejects.toMatchObject({name: 'NoSuchKey'});
expect(ceph.objects.has(`${CDN}/attachments/1/4/missing.pdf`)).toBe(false);
});
it('does not retry a failed same-bucket copy through the API', async () => {
await expect(
storage().copyObject({
sourceBucket: CDN,
sourceKey: 'missing',
destinationBucket: CDN,
destinationKey: 'other',
newContentType: 'image/png',
}),
).rejects.toMatchObject({name: 'NoSuchKey'});
expect(ceph.copies).toEqual([{source: `${CDN}/missing`, destination: `${CDN}/other`}]);
});
});
@@ -24,5 +24,7 @@ export abstract class IInviteRepository {
abstract updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void>;
abstract compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean>;
abstract delete(code: InviteCode): Promise<void>;
}
+23 -6
View File
@@ -2,7 +2,13 @@
import type {ChannelID, GuildID, InviteCode, UserID} from '@app/api/BrandedTypes';
import {createInviteCode} from '@app/api/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {
BatchBuilder,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import type {InviteRow} from '@app/api/database/types/ChannelTypes';
import {IInviteRepository} from '@app/api/invite/IInviteRepository';
@@ -168,17 +174,13 @@ export class InviteRepository extends IInviteRepository {
async updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void> {
if (invite.maxAge > 0) {
const remainingTtl = Math.max(
Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000),
1,
);
await upsertOne(
Invites.patchByPkWithTtl(
{code},
{
uses: Db.set(uses),
},
remainingTtl,
this.remainingTtl(invite),
),
);
} else {
@@ -193,6 +195,21 @@ export class InviteRepository extends IInviteRepository {
}
}
async compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean> {
const patch = {uses: Db.set(uses)};
const expected = {uses: invite.uses};
if (invite.maxAge > 0) {
return executeConditional(
Invites.conditionalPatchByPkWithTtl({code: invite.code}, patch, expected, this.remainingTtl(invite)),
);
}
return executeConditional(Invites.conditionalPatchByPk({code: invite.code}, patch, expected));
}
private remainingTtl(invite: Invite): number {
return Math.max(Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000), 1);
}
async delete(code: InviteCode): Promise<void> {
const invite = await this.findUnique(code);
if (!invite) {
+74 -24
View File
@@ -33,6 +33,8 @@ import type {
GuildInviteMetadataResponse,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
const INVITE_USE_RESERVATION_EXTRA_ATTEMPTS = 8;
interface GetChannelInvitesParams {
userId: UserID;
channelId: ChannelID;
@@ -262,13 +264,17 @@ export class InviteService {
return invite;
}
if (user) assertAccountNotLimited(user);
await this.channelService.groupDms.addRecipientViaInvite({
channelId: invite.channelId,
recipientId: userId,
inviterId: invite.inviterId,
requestCache,
});
return this.incrementInviteUses(invite, {deleteWhenExhausted: true});
const channelId = invite.channelId;
const reservedInvite = await this.reserveInviteUse(invite);
await this.withReservedInviteUse(reservedInvite, () =>
this.channelService.groupDms.addRecipientViaInvite({
channelId,
recipientId: userId,
inviterId: invite.inviterId,
requestCache,
}),
);
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: true});
}
if (!invite.guildId) throw new UnknownInviteError();
const guild = await this.guildService.data.getGuildSystem(invite.guildId);
@@ -294,20 +300,24 @@ export class InviteService {
}
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
const isVanityInvite = invite.code === vanityCode;
await this.guildService.members.addUserToGuild({
userId,
guildId: invite.guildId,
sendJoinMessage: true,
requestCache,
isTemporary: invite.temporary,
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
sourceInviteCode: isVanityInvite ? undefined : invite.code,
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
});
const guildId = invite.guildId;
const reservedInvite = await this.reserveInviteUse(invite);
await this.withReservedInviteUse(reservedInvite, () =>
this.guildService.members.addUserToGuild({
userId,
guildId,
sendJoinMessage: true,
requestCache,
isTemporary: invite.temporary,
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
sourceInviteCode: isVanityInvite ? undefined : invite.code,
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
}),
);
if (invite.temporary) {
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId: invite.guildId});
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId});
}
return this.incrementInviteUses(invite, {deleteWhenExhausted: !isVanityInvite});
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: !isVanityInvite});
}
private createRandomInviteCode(): InviteCode {
@@ -326,13 +336,53 @@ export class InviteService {
});
}
private async incrementInviteUses(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
const newUses = invite.uses + 1;
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
if (params.deleteWhenExhausted && invite.maxUses > 0 && newUses >= invite.maxUses) {
private async reserveInviteUse(invite: Invite): Promise<Invite> {
if (invite.maxUses <= 0) return invite;
let current: Invite | null = invite;
for (let attempt = 0; attempt <= invite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
if (!current || current.uses >= current.maxUses) break;
const reservedUses = current.uses + 1;
if (await this.inviteRepository.compareAndSetInviteUses(current, reservedUses)) {
return this.cloneInviteWithUses(current, reservedUses);
}
current = await this.inviteRepository.findUnique(invite.code);
}
throw new UnknownInviteError();
}
private async withReservedInviteUse(reservedInvite: Invite, join: () => Promise<unknown>): Promise<void> {
try {
await join();
} catch (error) {
await this.releaseInviteUse(reservedInvite);
throw error;
}
}
private async releaseInviteUse(reservedInvite: Invite): Promise<void> {
if (reservedInvite.maxUses <= 0) return;
try {
let current = await this.inviteRepository.findUnique(reservedInvite.code);
for (let attempt = 0; attempt <= reservedInvite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
if (!current || current.uses <= 0) return;
if (await this.inviteRepository.compareAndSetInviteUses(current, current.uses - 1)) return;
current = await this.inviteRepository.findUnique(reservedInvite.code);
}
} catch (error) {
Logger.error({error, inviteCode: reservedInvite.code}, 'Failed to release reserved invite use');
}
}
private async completeInviteUse(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
if (invite.maxUses <= 0) {
const newUses = invite.uses + 1;
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
return this.cloneInviteWithUses(invite, newUses);
}
if (params.deleteWhenExhausted && invite.uses >= invite.maxUses) {
await this.inviteRepository.delete(invite.code);
}
return this.cloneInviteWithUses(invite, newUses);
return invite;
}
private async findInviteWithLowercaseFallback(inviteCode: InviteCode): Promise<Invite | null> {
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {MAX_GUILD_MEMBERS} from '@fluxer/constants/src/LimitConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface InviteResponse {
code: string;
uses?: number;
}
async function setupInvite(harness: ApiTestHarness, maxUses: number, joinerCount: number) {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Max uses guild');
if (!guild.system_channel_id) {
throw new Error('Guild system channel is missing');
}
const invite = await createBuilder<InviteResponse>(harness, owner.token)
.post(`/channels/${guild.system_channel_id}/invites`)
.body({max_uses: maxUses, unique: true})
.execute();
const joiners: Array<TestAccount> = [];
for (let i = 0; i < joinerCount; i++) {
joiners.push(await createTestAccount(harness));
}
return {owner, guild, invite, joiners};
}
async function countMembers(harness: ApiTestHarness, accounts: Array<TestAccount>, guildId: string): Promise<number> {
let count = 0;
for (const account of accounts) {
const guilds = await createBuilder<Array<{id: string}>>(harness, account.token).get('/users/@me/guilds').execute();
if (guilds.some((guild) => guild.id === guildId)) count++;
}
return count;
}
async function findGuildInvite(
harness: ApiTestHarness,
token: string,
guildId: string,
code: string,
): Promise<InviteResponse | null> {
const invites = await createBuilder<Array<InviteResponse>>(harness, token)
.get(`/guilds/${guildId}/invites`)
.execute();
return invites.find((invite) => invite.code === code) ?? null;
}
describe('Invite max uses', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness?.shutdown();
});
beforeEach(async () => {
await harness.reset();
});
it.each([
[1, 8],
[3, 10],
])('admits at most max_uses=%i of %i simultaneous joiners', async (maxUses, joinerCount) => {
const {owner, guild, invite, joiners} = await setupInvite(harness, maxUses, joinerCount);
const responses = await Promise.all(
joiners.map((joiner) =>
createBuilder(harness, joiner.token).post(`/invites/${invite.code}`).body(null).executeRaw(),
),
);
const statuses = responses.map((result) => result.response.status);
expect(statuses.filter((status) => status === HTTP_STATUS.OK)).toHaveLength(maxUses);
expect(
statuses.filter((status) => status !== HTTP_STATUS.OK).every((status) => status === HTTP_STATUS.NOT_FOUND),
).toBe(true);
expect(await countMembers(harness, joiners, guild.id)).toBe(maxUses);
expect(await findGuildInvite(harness, owner.token, guild.id, invite.code)).toBeNull();
});
it('counts every use when joiners arrive together', async () => {
const {owner, guild, invite, joiners} = await setupInvite(harness, 10, 4);
await Promise.all(
joiners.map((joiner) =>
createBuilder(harness, joiner.token)
.post(`/invites/${invite.code}`)
.body(null)
.expect(HTTP_STATUS.OK)
.execute(),
),
);
const after = await findGuildInvite(harness, owner.token, guild.id, invite.code);
expect(after?.uses).toBe(4);
});
it('returns the use when the join fails', async () => {
const {owner, guild, invite, joiners} = await setupInvite(harness, 1, 2);
const [first, second] = joiners;
await createBuilder(harness, '')
.post(`/test/guilds/${guild.id}/member-count`)
.body({member_count: MAX_GUILD_MEMBERS})
.execute();
await createBuilder(harness, first!.token)
.post(`/invites/${invite.code}`)
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_MEMBERS)
.execute();
const afterFailure = await findGuildInvite(harness, owner.token, guild.id, invite.code);
expect(afterFailure?.uses).toBe(0);
await createBuilder(harness, '').post(`/test/guilds/${guild.id}/member-count`).body({member_count: 1}).execute();
await createBuilder(harness, second!.token)
.post(`/invites/${invite.code}`)
.body(null)
.expect(HTTP_STATUS.OK)
.execute();
expect(await countMembers(harness, [second!], guild.id)).toBe(1);
});
});
@@ -4,6 +4,7 @@ import {Config} from '@app/api/Config';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {Context, MiddlewareHandler} from 'hono';
import type {ZodType} from 'zod';
@@ -53,7 +54,7 @@ async function validateAndRewriteResponse(ctx: Context<HonoEnv>, schema: ZodType
}));
const errorContext = {
method: ctx.req.method,
path: ctx.req.path,
path: resolveRoutePattern(ctx),
status: response.status,
validationErrors,
body,
@@ -187,6 +187,7 @@ function createEmailServiceForConfig(
enabled: emailConfigSource.enabled,
fromEmail: emailConfigSource.fromEmail,
fromName: emailConfigSource.fromName,
replyTo: emailConfigSource.replyToEmail || null,
appBaseUrl: emailConfigSource.appBaseUrl,
marketingBaseUrl: Config.endpoints.marketing,
};
@@ -462,6 +463,7 @@ export const getGuildDiscoveryService = singleton(
getGuildRepository(),
getGatewayService(),
getGuildSearchService(),
getChannelRepository().channelData,
),
);
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
@@ -102,7 +102,7 @@ describe('client ip resolution across the request pipeline', () => {
expect(pipeline.resolutions[0]?.ip).toBe('203.0.113.10');
expect(pipeline.resolutions[1]?.ip).toBe('203.0.113.10');
});
it('rejects an invalid trusted header even when the configured header carries a valid address', async () => {
it('rejects an invalid trusted header even when the configured header contains a valid address', async () => {
const pipeline = createPipeline('x-real-ip');
const response = await pipeline.request({'x-forwarded-for': '203.0.113.10', 'x-real-ip': 'not-an-ip'});
expect(response.status).toBe(403);
+7 -2
View File
@@ -304,7 +304,10 @@ export class OAuth2Service {
if (authCode.userId && !(await this.findActiveUser(authCode.userId))) {
throw new InvalidGrantError();
}
await this.tokens.deleteAuthorizationCode(code);
if (!(await this.tokens.consumeAuthorizationCode(code, authCode.applicationId))) {
Logger.debug({code_len: code.length}, 'OAuth2 tokenExchange: authorization code already redeemed');
throw new InvalidGrantError();
}
const res = await this.issueTokens({
application,
userId: authCode.userId,
@@ -328,7 +331,9 @@ export class OAuth2Service {
if (!(await this.findActiveUser(refresh.userId))) {
throw new InvalidGrantError();
}
await this.tokens.deleteRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId);
if (!(await this.tokens.consumeRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId))) {
throw new InvalidGrantError();
}
const res = await this.issueTokens({
application,
userId: refresh.userId,
@@ -14,13 +14,14 @@ export interface IOAuth2TokenRepository {
createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode>;
getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null>;
deleteAuthorizationCode(code: string): Promise<void>;
consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean>;
createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken>;
getAccessToken(token: string): Promise<OAuth2AccessToken | null>;
deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise<void>;
deleteAllAccessTokensForUser(userId: UserID): Promise<void>;
createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken>;
getRefreshToken(token: string): Promise<OAuth2RefreshToken | null>;
deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<void>;
consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean>;
deleteAllRefreshTokensForUser(userId: UserID): Promise<void>;
listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>>;
deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void>;
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {
BatchBuilder,
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import type {
OAuth2AccessTokenByUserRow,
OAuth2AccessTokenRow,
@@ -71,6 +78,10 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
await deleteOneOrMany(OAuth2AuthorizationCodes.deleteByPk({code}));
}
async consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean> {
return executeConditional(OAuth2AuthorizationCodes.conditionalDeleteByPk({code}, {application_id: applicationId}));
}
async createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2AccessTokens.insertWithTtl(data, ACCESS_TOKEN_TTL_SECONDS));
@@ -142,11 +153,14 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
return row ? new OAuth2RefreshToken(row) : null;
}
async deleteRefreshToken(token: string, _applicationId: ApplicationID, userId: UserID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: token}));
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
await batch.execute();
async consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean> {
const consumed = await executeConditional(
OAuth2RefreshTokens.conditionalDeleteByPk({token_: token}, {application_id: applicationId, user_id: userId}),
);
if (consumed) {
await deleteOneOrMany(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
}
return consumed;
}
async deleteAllRefreshTokensForUser(userId: UserID): Promise<void> {
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@app/api/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const CONCURRENT_REQUESTS = 8;
interface TokenResult {
status: number;
accessToken: string | null;
}
function addQueryLatency(): void {
const executeQuery = InMemoryCassandraQueryExecutor.prototype.executeQuery;
vi.spyOn(InMemoryCassandraQueryExecutor.prototype, 'executeQuery').mockImplementation(async function (
this: InMemoryCassandraQueryExecutor,
...args: Parameters<typeof executeQuery>
) {
await new Promise((resolve) => setTimeout(resolve, 1));
return executeQuery.apply(this, args);
} as typeof executeQuery);
}
async function postToken(
harness: ApiTestHarness,
clientId: string,
clientSecret: string,
form: Record<string, string>,
): Promise<TokenResult> {
const response = await harness.app.request('/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
},
body: new URLSearchParams(form).toString(),
});
const body = (await response.json().catch(() => null)) as {access_token?: string} | null;
return {status: response.status, accessToken: body?.access_token ?? null};
}
async function postConcurrently(
harness: ApiTestHarness,
clientId: string,
clientSecret: string,
form: Record<string, string>,
): Promise<Array<TokenResult>> {
addQueryLatency();
try {
return await Promise.all(
Array.from({length: CONCURRENT_REQUESTS}, () => postToken(harness, clientId, clientSecret, form)),
);
} finally {
vi.restoreAllMocks();
}
}
function expectSingleSuccess(results: Array<TokenResult>): void {
const succeeded = results.filter((result) => result.status === HTTP_STATUS.OK);
expect(succeeded).toHaveLength(1);
expect(succeeded[0]!.accessToken).toBeTruthy();
expect(results.filter((result) => result.status === HTTP_STATUS.BAD_REQUEST)).toHaveLength(CONCURRENT_REQUESTS - 1);
}
describe('OAuth2 concurrent grant redemption', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
vi.restoreAllMocks();
await harness?.shutdown();
});
test('redeems an authorization code once when requests overlap', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const {code} = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const results = await postConcurrently(harness, application.id, application.client_secret, {
grant_type: 'authorization_code',
code,
redirect_uri: redirectURI,
client_id: application.id,
});
expectSingleSuccess(results);
});
test('rotates a refresh token once when requests overlap', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const {code} = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initial = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code,
redirect_uri: redirectURI,
});
const results = await postConcurrently(harness, application.id, application.client_secret, {
grant_type: 'refresh_token',
refresh_token: initial.refresh_token!,
client_id: application.id,
});
expectSingleSuccess(results);
});
});
+385 -21
View File
@@ -5541,6 +5541,77 @@
]
}
},
"/discovery/guilds/{guild_id}/channels/{channel_id}": {
"get": {
"operationId": "get_discovery_channel_preview",
"summary": "Preview a channel in a discoverable guild",
"tags": ["Discovery"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryChannelPreviewResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the guild", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
},
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
]
}
},
"/discovery/guilds/{guild_id}/join": {
"post": {
"operationId": "join_discovery_guild",
@@ -13912,7 +13983,7 @@
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPrivateResponse"}}}
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdateResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -13953,7 +14024,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile.",
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile. A password change invalidates all existing sessions and returns the replacement session token.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": false,
@@ -21543,7 +21614,7 @@
]
},
"referenced_message": {
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
"anyOf": [
{
"type": "object",
@@ -25300,6 +25371,250 @@
"webauthn_challenge": {"description": "WebAuthn challenge string", "type": "string"}
}
},
"UserUpdateResponse": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier (snowflake) for this user",
"$ref": "#/components/schemas/SnowflakeStringType"
},
"username": {"type": "string", "description": "The username of the user, not unique across the platform"},
"discriminator": {"type": "string", "description": "The four-digit discriminator tag of the user"},
"global_name": {"description": "The display name of the user, if set", "type": ["string", "null"]},
"avatar": {"description": "The hash of the user avatar image", "type": ["string", "null"]},
"avatar_color": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The dominant avatar color of the user as an integer"
},
"bot": {"description": "Whether the user is a bot account", "type": "boolean"},
"system": {"description": "Whether the user is an official system user", "type": "boolean"},
"flags": {"$ref": "#/components/schemas/PublicUserFlags"},
"mention_flags": {
"description": "The user's account-wide reply mention preference. Omitted when the user has no preference set (treated as NO_PREFERENCE).",
"$ref": "#/components/schemas/MentionReplyPreferences"
},
"is_staff": {"type": "boolean", "description": "Whether the user has staff permissions"},
"acls": {
"type": "array",
"items": {"type": "string"},
"description": "Access control list entries for the user"
},
"traits": {
"type": "array",
"items": {"type": "string"},
"description": "Special traits assigned to the user account"
},
"email": {"description": "The email address associated with the account", "type": ["string", "null"]},
"email_bounced": {
"description": "Whether the current email address is marked as bounced by the mail provider",
"type": "boolean"
},
"has_verified_phone": {"type": "boolean", "description": "Deprecated. Always false."},
"bio": {"description": "The user biography text", "type": ["string", "null"]},
"pronouns": {"description": "The preferred pronouns of the user", "type": ["string", "null"]},
"accent_color": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The user-selected accent color as an integer"
},
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
"banner_color": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The default banner color if no custom banner is set"
},
"mfa_enabled": {"type": "boolean", "description": "Whether multi-factor authentication is enabled"},
"authenticator_types": {
"description": "The types of authenticators configured for MFA",
"type": "array",
"items": {"$ref": "#/components/schemas/UserAuthenticatorTypes"}
},
"verified": {"type": "boolean", "description": "Whether the email address has been verified"},
"account_limited": {"description": "Whether the account is limited", "type": "boolean"},
"premium_type": {
"anyOf": [
{"$ref": "#/components/schemas/UserPremiumTypes", "description": "The type of premium subscription"},
{"type": "null"}
]
},
"premium_since": {
"description": "ISO8601 timestamp of when premium was first activated",
"type": ["string", "null"]
},
"premium_until": {
"description": "ISO8601 timestamp of when premium access ends, including stacked gift time",
"type": ["string", "null"]
},
"premium_will_cancel": {
"type": "boolean",
"description": "Whether premium is set to cancel at the end of the billing period"
},
"premium_billing_cycle": {
"description": "The billing cycle for the premium subscription",
"type": ["string", "null"]
},
"premium_lifetime_sequence": {
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
"description": "The sequence number for lifetime premium subscribers"
},
"premium_grace_ends_at": {
"description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.",
"type": ["string", "null"]
},
"premium_discriminator": {
"type": "boolean",
"description": "Whether the user selected a premium-only discriminator that will be rerolled when non-lifetime premium access ends"
},
"premium_badge_hidden": {
"type": "boolean",
"description": "Whether the premium badge is hidden on the profile"
},
"premium_badge_masked": {
"type": "boolean",
"description": "Whether the premium badge shows a masked appearance"
},
"premium_badge_timestamp_hidden": {
"type": "boolean",
"description": "Whether the premium start timestamp is hidden"
},
"premium_badge_sequence_hidden": {
"type": "boolean",
"description": "Whether the lifetime sequence number is hidden"
},
"premium_purchase_disabled": {
"type": "boolean",
"description": "Whether premium purchases are disabled for this account"
},
"premium_enabled_override": {
"type": "boolean",
"description": "Whether premium features are enabled via override"
},
"premium_perks_disabled": {
"type": "boolean",
"description": "Whether premium perks are temporarily disabled for this account"
},
"password_last_changed_at": {
"description": "ISO8601 timestamp of the last password change",
"type": ["string", "null"]
},
"last_voice_activity_sharing_change_at": {
"description": "ISO8601 timestamp of the last bulk voice-activity-sharing change. Drives the 24-hour cooldown for re-toggling the Active Now sharing default.",
"type": ["string", "null"]
},
"required_actions": {
"type": "array",
"items": {"type": "string"},
"description": "Deprecated. Always empty."
},
"nsfw_allowed": {"type": "boolean", "description": "Whether the user is allowed to view NSFW content"},
"has_dismissed_premium_onboarding": {
"type": "boolean",
"description": "Whether the user has dismissed the premium onboarding flow"
},
"has_ever_purchased": {"type": "boolean", "description": "Whether the user has ever made a purchase"},
"has_unread_gift_inventory": {
"type": "boolean",
"description": "Whether there are unread items in the gift inventory"
},
"unread_gift_inventory_count": {
"description": "The number of unread gift inventory items",
"$ref": "#/components/schemas/Int32Type"
},
"pending_bulk_message_deletion": {
"anyOf": [
{
"type": "object",
"properties": {
"scheduled_at": {
"type": "string",
"description": "ISO8601 timestamp of when the deletion was scheduled"
},
"channel_count": {
"description": "The number of channels with messages to delete",
"$ref": "#/components/schemas/Int32Type"
},
"message_count": {
"description": "The total number of messages to delete",
"$ref": "#/components/schemas/Int32Type"
}
},
"required": ["scheduled_at", "channel_count", "message_count"],
"additionalProperties": false
},
{"type": "null"}
],
"description": "Information about a pending bulk message deletion request. Only populated when the legacy delayed-deletion flow is in progress; the new immediate-deletion flow does not surface a pending state here."
},
"age_verified_adult": {
"description": "Whether the user has verified their age as an adult via credit card verification",
"type": "boolean"
},
"terms_agreed_at": {
"description": "ISO8601 timestamp of when the user last agreed to the terms of service",
"type": ["string", "null"]
},
"privacy_agreed_at": {
"description": "ISO8601 timestamp of when the user last agreed to the privacy policy",
"type": ["string", "null"]
},
"token": {
"description": "Authentication token for the replacement session, present when the password was changed",
"type": "string"
},
"auth_session_id_hash": {
"description": "Base64url-encoded hash of the replacement authentication session, present when the password was changed",
"type": "string"
}
},
"required": [
"id",
"username",
"discriminator",
"global_name",
"avatar",
"avatar_color",
"flags",
"is_staff",
"acls",
"traits",
"email",
"has_verified_phone",
"bio",
"pronouns",
"accent_color",
"banner",
"banner_color",
"mfa_enabled",
"verified",
"premium_type",
"premium_since",
"premium_until",
"premium_will_cancel",
"premium_billing_cycle",
"premium_lifetime_sequence",
"premium_grace_ends_at",
"premium_discriminator",
"premium_badge_hidden",
"premium_badge_masked",
"premium_badge_timestamp_hidden",
"premium_badge_sequence_hidden",
"premium_purchase_disabled",
"premium_enabled_override",
"premium_perks_disabled",
"password_last_changed_at",
"last_voice_activity_sharing_change_at",
"required_actions",
"nsfw_allowed",
"has_dismissed_premium_onboarding",
"has_ever_purchased",
"has_unread_gift_inventory",
"unread_gift_inventory_count",
"pending_bulk_message_deletion",
"terms_agreed_at",
"privacy_agreed_at"
],
"additionalProperties": false
},
"UnfurlRequest": {
"type": "object",
"properties": {"url": {"description": "The URL to unfurl", "type": "string"}},
@@ -28335,6 +28650,35 @@
"required": ["url"],
"additionalProperties": false
},
"DiscoveryChannelPreviewResponse": {
"type": "object",
"properties": {
"guild": {
"type": "object",
"properties": {
"id": {"description": "Guild ID", "$ref": "#/components/schemas/SnowflakeStringType"},
"name": {"type": "string", "description": "Guild name"},
"icon": {"description": "Guild icon hash", "type": ["string", "null"]}
},
"required": ["id", "name", "icon"],
"additionalProperties": false,
"description": "The discoverable guild the channel belongs to"
},
"channel": {
"type": "object",
"properties": {
"id": {"description": "Channel ID", "$ref": "#/components/schemas/SnowflakeStringType"},
"name": {"description": "Channel name", "type": ["string", "null"]},
"type": {"type": "number", "description": "Channel type"}
},
"required": ["id", "name", "type"],
"additionalProperties": false,
"description": "A channel that new members can view"
}
},
"required": ["guild", "channel"],
"additionalProperties": false
},
"DiscoveryGuildListResponse": {
"type": "object",
"properties": {
@@ -30548,7 +30892,7 @@
"original": {"type": "string", "description": "The requested URL, echoed back unchanged"},
"refreshed": {
"type": "string",
"description": "The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours"
"description": "The same URL with a fresh signature, or the original when it is not an attachment URL of ours"
}
},
"required": ["original", "refreshed"],
@@ -32300,9 +32644,15 @@
"description": "The template-local channel ID"
},
"type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"},
"name": {"description": "The name of the channel", "type": ["string", "null"]},
"topic": {"description": "The channel topic", "type": ["string", "null"]},
"position": {"type": "number", "description": "The position of the channel"},
"name": {
"description": "The name of the channel",
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
},
"topic": {
"description": "The channel topic",
"anyOf": [{"type": "string", "maxLength": 1024}, {"type": "null"}]
},
"position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"},
"parent_id": {
"description": "The template-local ID of the parent category",
"anyOf": [
@@ -32313,14 +32663,25 @@
{"type": "null"}
]
},
"bitrate": {"description": "The bitrate for voice channels", "type": ["number", "null"]},
"user_limit": {"description": "The user limit for voice channels", "type": ["number", "null"]},
"bitrate": {
"description": "The bitrate for voice channels",
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
},
"user_limit": {
"description": "The user limit for voice channels",
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
},
"voice_connection_limit": {
"description": "The per-user voice connection limit for voice channels",
"type": ["number", "null"]
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
},
"nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"},
"rate_limit_per_user": {"description": "Slowmode rate limit in seconds", "type": "number"},
"rate_limit_per_user": {
"description": "Slowmode rate limit in seconds",
"type": "integer",
"minimum": 0,
"maximum": 21600
},
"permission_overwrites": {
"description": "Permission overwrites for this channel",
"type": "array",
@@ -32357,7 +32718,10 @@
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
"description": "The template-local role ID"
},
"name": {"description": "The name of the role", "type": ["string", "null"]},
"name": {
"description": "The name of the role",
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
},
"permissions": {
"description": "The permissions bitfield as a string (legacy)",
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
@@ -32366,7 +32730,7 @@
"description": "The permissions bitfield as a string (preferred)",
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
},
"color": {"description": "The colour of the role as an integer", "type": "number"},
"color": {"description": "The colour of the role as an integer", "$ref": "#/components/schemas/ColorType"},
"hoist": {"description": "Whether the role is hoisted", "type": "boolean"},
"mentionable": {"description": "Whether the role is mentionable", "type": "boolean"},
"unicode_emoji": {"description": "The unicode emoji for the role icon", "type": ["string", "null"]}
@@ -35032,6 +35396,14 @@
"required": ["src", "proxy_src", "width", "height"],
"additionalProperties": false
},
"UserAuthenticatorTypes": {
"description": "Authenticator type",
"type": "integer",
"enum": [0, 2],
"format": "int32",
"x-enumNames": ["TOTP", "WEBAUTHN"],
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
},
"ProfileFieldPrivacyFlags": {
"type": "integer",
"minimum": 0,
@@ -35283,14 +35655,6 @@
"required": ["id", "rawId", "type", "clientExtensionResults", "response"],
"additionalProperties": {}
},
"UserAuthenticatorTypes": {
"description": "Authenticator type",
"type": "integer",
"enum": [0, 2],
"format": "int32",
"x-enumNames": ["TOTP", "WEBAUTHN"],
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
},
"HexString32Type": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
"CompletedPasskeyBridgeSudoRedeemResponse": {
"type": "object",
@@ -16,6 +16,10 @@ export const DiscoveryRateLimitConfigs = {
bucket: 'discovery:join',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
DISCOVERY_CHANNEL_PREVIEW: {
bucket: 'discovery:channel_preview',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
DISCOVERY_APPLY: {
bucket: 'discovery:apply::guild_id',
config: {limit: 5, windowMs: ms('1 minute')},
@@ -51,7 +51,7 @@ describe('POST /test/rpc-session-init harness access', () => {
.execute();
});
test('rejects a session init carrying the wrong harness token', async () => {
test('rejects a session init with the wrong harness token', async () => {
const account = await createTestAccount(harness);
Config.dev.testHarnessToken = HARNESS_TOKEN;
await createBuilder(harness, '')
@@ -62,7 +62,7 @@ describe('POST /test/rpc-session-init harness access', () => {
.execute();
});
test('accepts a session init carrying the harness token', async () => {
test('accepts a session init with the harness token', async () => {
const account = await createTestAccount(harness);
Config.dev.testHarnessToken = HARNESS_TOKEN;
const response = await createBuilder<RpcSessionResponse>(harness, '')
@@ -569,7 +569,7 @@ describe('Message Search Filters', () => {
}
}
});
test('has: snapshot combined with has: image finds forwards whose snapshot carries an image', async () => {
test('has: snapshot combined with has: image finds forwards whose snapshot has an image', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Forward Image Guild');
const sourceChannelId = guild.system_channel_id!;
@@ -370,7 +370,7 @@ describe('App Store JWS verification with a test chain', () => {
);
});
it('rejects a leaf that carries the identifier only as a policy', async () => {
it('rejects a leaf that has the identifier only as a policy', async () => {
const policyOnly = createAppleTestPki({
leaf: {appleExtension: false, extraExtensions: [certificatePoliciesExtension(APPLE_RECEIPT_SIGNING_OID)]},
});
@@ -262,7 +262,7 @@ describe('App Store purchases', () => {
};
}
it('grants premium for a claim that carries the account token and answers repeats the same way', async () => {
it('grants premium for a claim that includes the account token and answers repeats the same way', async () => {
const account = await createTestAccount(harness);
const token = await accountToken(account);
const expiresDate = Date.now() + ms('30 days');
+1 -1
View File
@@ -23,7 +23,7 @@ export async function useCheapCaptcha(): Promise<void> {
export async function solveCaptchaChallenge(body: CaptchaErrorBody): Promise<string> {
const challenge = body.altcha_challenge;
if (!challenge) throw new Error('The response carried no ALTCHA challenge');
if (!challenge) throw new Error('The response had no ALTCHA challenge');
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
if (!solution) throw new Error('The ALTCHA challenge was not solved');
const payload = {challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution};
@@ -78,6 +78,7 @@ import {
UserProfileFullResponse,
UserSettingsResponse,
UserTagCheckResponse,
UserUpdateResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {uint8ArrayToBase64} from 'uint8array-extras';
@@ -118,12 +119,12 @@ export function UserAccountController(app: HonoApp) {
OpenAPI({
operationId: 'update_current_user',
summary: 'Update current user profile',
responseSchema: UserPrivateResponse,
responseSchema: UserUpdateResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
"Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile.",
"Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile. A password change invalidates all existing sessions and returns the replacement session token.",
}),
async (ctx) => {
const userAccountRequestService = ctx.get('userAccountRequestService');
@@ -12,6 +12,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {assertValidTotpSetupCode} from '@app/api/user/services/UserAuth';
import {Validator} from '@app/api/Validator';
import {
DisableTotpRequest,
@@ -60,6 +61,7 @@ export function UserAuthController(app: HonoApp) {
async (ctx) => {
const body = ctx.req.valid('json');
const user = ctx.get('user');
await assertValidTotpSetupCode(body.secret, body.code);
const sudoResult = await requireSudoMode(ctx, user, body);
return ctx.json(
await ctx.get('userAuthRequestService').enableTotp({
@@ -35,7 +35,11 @@ import type {
EmailChangeApplyRequest,
UserUpdateWithVerificationRequest,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {
UserPrivateResponse,
UserProfileFullResponse,
UserUpdateResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {Context} from 'hono';
type UserUpdatePayload = Omit<
@@ -127,7 +131,7 @@ export class UserAccountRequestService {
user: User;
body: UserUpdateWithVerificationRequest;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
}): Promise<UserUpdateResponse> {
const {ctx, body, authSession} = params;
const {user} = params;
const oldEmail = user.email;
@@ -180,7 +184,7 @@ export class UserAccountRequestService {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
}
const updatedUser = await this.userAccountService.update({
const {user: updatedUser, authSessionReplacement} = await this.userAccountService.update({
user,
oldAuthSession: authSession,
data: userUpdateData,
@@ -224,7 +228,15 @@ export class UserAccountRequestService {
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
}
}
return mapUserToPrivateResponse(updatedUser);
const response = mapUserToPrivateResponse(updatedUser);
if (!authSessionReplacement) {
return response;
}
return {
...response,
token: authSessionReplacement.token,
auth_session_id_hash: authSessionReplacement.authSessionIdHash,
};
}
async applyEmailChange(params: {
@@ -41,6 +41,11 @@ interface UserAccountSecurityServiceDeps {
limitConfigService: LimitConfigService;
}
export interface AuthSessionReplacement {
token: string;
authSessionIdHash: string;
}
export class UserAccountSecurityService {
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
@@ -158,12 +163,13 @@ export class UserAccountSecurityService {
user: User;
oldAuthSession: AuthSessionModel;
request: Request;
}): Promise<void> {
await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, {
}): Promise<AuthSessionReplacement> {
const replacement = await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, {
user,
currentAuthSession: oldAuthSession,
request,
});
return {token: replacement.token, authSessionIdHash: replacement.newAuthSessionIdHash};
}
private async createSudoModeRequiredError(user: User): Promise<SudoModeRequiredError> {
@@ -22,7 +22,10 @@ import {UserAccountLifecycleService} from '@app/api/user/services/UserAccountLif
import {UserAccountLookupService} from '@app/api/user/services/UserAccountLookupService';
import {UserAccountNotesService} from '@app/api/user/services/UserAccountNotesService';
import {UserAccountProfileService} from '@app/api/user/services/UserAccountProfileService';
import {UserAccountSecurityService} from '@app/api/user/services/UserAccountSecurityService';
import {
type AuthSessionReplacement,
UserAccountSecurityService,
} from '@app/api/user/services/UserAccountSecurityService';
import {UserAccountSettingsService} from '@app/api/user/services/UserAccountSettingsService';
import {UserAccountUpdatePropagator} from '@app/api/user/services/UserAccountUpdatePropagator';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
@@ -41,6 +44,11 @@ interface UpdateUserParams {
emailVerifiedViaToken?: boolean;
}
interface UpdateUserResult {
user: User;
authSessionReplacement: AuthSessionReplacement | null;
}
interface UserAccountRepository
extends IUserAccountRepository,
IUserSettingsRepository,
@@ -137,7 +145,7 @@ export class UserAccountService {
});
}
async update(params: UpdateUserParams): Promise<User> {
async update(params: UpdateUserParams): Promise<UpdateUserResult> {
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
const profileResult = await this.profileService.processProfileUpdates({user, data});
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
@@ -195,14 +203,21 @@ export class UserAccountService {
}
},
];
let authSessionReplacement: AuthSessionReplacement | null = null;
if (securityResult.metadata.invalidateAuthSessions) {
finalizationSteps.push(
() => this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request}),
async () => {
authSessionReplacement = await this.securityService.invalidateAndRecreateSessions({
user,
oldAuthSession,
request,
});
},
() => this.userAccountRepository.deleteAllPasswordResetTokens(user.id),
);
}
await runAllInOrder(finalizationSteps, 'Failed to finalize user update');
return updatedUser;
return {user: updatedUser, authSessionReplacement};
}
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
@@ -8,6 +8,7 @@ import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificati
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
import type {User} from '@app/api/models/User';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotDisabledError} from '@fluxer/errors/src/domains/auth/MfaNotDisabledError';
@@ -55,6 +56,20 @@ async function assertSudoVerifiedForMfa(
);
}
async function isValidTotpSetupCode(secret: string, code: string): Promise<boolean> {
try {
return await new TotpGenerator(secret).validateTotp(code);
} catch {
return false;
}
}
export async function assertValidTotpSetupCode(secret: string, code: string): Promise<void> {
if (!(await isValidTotpSetupCode(secret, code))) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
}
export async function enableMfaTotp(
ctx: ApiContext,
{user, secret, code, sudoContext}: EnableMfaTotpParams,
@@ -29,7 +29,7 @@ async function clearFlags(harness: ApiTestHarness, userId: string, flags: Array<
async function expectLimited(request: Promise<{json: ErrorResponse}>): Promise<void> {
const {json} = await request;
expect(json.code).toBe(APIErrorCodes.ACCOUNT_LIMITED);
expect(json.message).toBe('Your account is limited. Check your email for how to lift it.');
expect(json.message).toBe('Messaging is paused on your account. Check your email for a quick step to continue.');
}
describe('Account limitation', () => {
@@ -132,7 +132,7 @@ describe('Favorite Meme Operations', () => {
expect(sent.attachments[0].filename).toBe(filename);
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
});
test('should carry the saved placeholder onto the sent attachment', async () => {
test('should copy the saved placeholder onto the sent attachment', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
@@ -87,7 +87,7 @@ describe('User profile text validation', () => {
await createBuilder(harness, account.token)
.put(`/users/@me/notes/${target.userId}`)
.header('content-type', 'text/plain')
.body({note: 'note carrying a blockedphrase value'})
.body({note: 'note with a blockedphrase value'})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED)
.execute();
await createBuilder(harness, account.token)
@@ -66,7 +66,7 @@ describe('resolveSessionClientInfo', () => {
});
});
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
it('treats a narrow Linux window as a desktop because the product token cannot include form factor', () => {
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
platform: 'Fluxer Lite Linux',
os: 'Linux',
@@ -96,7 +96,7 @@ describe('Webhook Instatus integration', () => {
expect(await countWebhookMessages(harness, owner.token, channelId, webhook.id)).toBe(1);
await deleteWebhook(harness, webhook.id, owner.token);
});
it('processes a callback carrying no identifier every time', async () => {
it('processes a callback with no identifier every time', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Instatus Unidentified Guild');
const channelId = guild.system_channel_id!;
@@ -84,7 +84,7 @@ describe('Bulk delete messages for users', () => {
return messages.filter((message) => message.author.id === userId).length;
}
it('carries the admin reason and the message count on the per-user audit row', async () => {
it('records the admin reason and the message count on the per-user audit row', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await sendChannelMessage(harness, member.token, systemChannel.id, 'first spam');
@@ -76,7 +76,7 @@ describe('bulkUpdateUserFlags task', () => {
clearWorkerDependencies();
});
test('writes a per-user audit row carrying the admin reason and records failed items', async () => {
test('writes a per-user audit row with the admin reason and records failed items', async () => {
const first = await createTestAccount(harness);
const second = await createTestAccount(harness);
const result = (await bulkUpdateUserFlags(
@@ -96,7 +96,7 @@ describe('Retired worker task types', () => {
expect(msg.ack).not.toHaveBeenCalled();
});
it('dead-letters a legacy job that carries no ledger id', async () => {
it('dead-letters a legacy job that has no ledger id', async () => {
const runner = createRunner();
const msg = createJobMessage(RETIRED_TASK_TYPE, {userId: '1', scheduledMessageId: '2'});
@@ -307,7 +307,7 @@ describe('ensureVideoDDExtension', () => {
expect(ddOf(sdp, '1')).toBe(13);
});
it('leaves a section that already carries the extension alone', () => {
it('leaves a section that already has the extension alone', () => {
const sdp = parse(`${singlePcOffer}\na=extmap:3 ${ddExtensionURI}`);
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 0)).toBe(3);
expect(sectionOf(sdp, '2').ext).toHaveLength(2);
@@ -214,7 +214,7 @@ describe('publisher data channels before negotiation', () => {
return {engine, created};
}
it('creates them on a renegotiation that already carries transceivers', async () => {
it('creates them on a renegotiation that already has transceivers', async () => {
const {engine, created} = engineWithPublisherChannels(false);
await engine.negotiate();
expect(created).toEqual(['publisher']);
@@ -44,7 +44,7 @@ export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unkn
export async function classifySetupUnauthorized(): Promise<SetupUnauthorizedCause> {
if (!SessionManager.token) return 'unknown';
if (!http.carriesAuthorization()) return 'origin_mismatch';
if (!http.hasAuthorization()) return 'origin_mismatch';
try {
const response = await http.get(Endpoints.USER_ME, {mode: 'silent'});
return response.status === 401 ? 'stale_session' : 'unknown';
@@ -245,6 +245,8 @@ export const Endpoints = {
DISCOVERY_GUILDS: '/discovery/guilds',
DISCOVERY_CATEGORIES: '/discovery/categories',
DISCOVERY_JOIN: (guildId: string) => `/discovery/guilds/${guildId}/join`,
DISCOVERY_CHANNEL_PREVIEW: (guildId: string, channelId: string) =>
`/discovery/guilds/${guildId}/channels/${channelId}`,
GUILD_DISCOVERY: (guildId: string) => `/guilds/${guildId}/discovery`,
CONNECTIONS: '/users/@me/connections',
CONNECTIONS_VERIFY_AND_CREATE: '/users/@me/connections/verify',
@@ -29,7 +29,6 @@ import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import * as InviteUtils from '@app/features/invite/utils/InviteUtils';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
import {useMessageReactions as useMessageReactionsSnapshot} from '@app/features/messaging/hooks/useMessageReactionStore';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {extractEmbeddableCodeLinkContent} from '@app/features/messaging/utils/EmbeddableCodeLinkContent';
@@ -38,7 +37,6 @@ import {buildMessageSnapshotCopyText} from '@app/features/messaging/utils/Messag
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import {canonicalizeMediaUrl, useSpoilerState} from '@app/features/messaging/utils/SpoilerUtils';
import markupStyles from '@app/features/theme/styles/Markup.module.css';
import matureStyles from '@app/features/theme/styles/MatureBlur.module.css';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import * as ThemeUtils from '@app/features/theme/utils/ThemeUtils';
import {StickerInlineMenuItems} from '@app/features/ui/action_menu/items/StickerContextMenuItems';
@@ -507,7 +505,6 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
const stickerRecord = Sticker.getStickerById(sticker.id);
const isMobile = MobileLayout.enabled;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(false, message.channelId);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -538,37 +535,17 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
/>
));
};
const handleRevealClick = useCallback(
(e: React.MouseEvent) => {
if (shouldBlur && canReveal) {
e.preventDefault();
e.stopPropagation();
reveal();
}
},
[shouldBlur, canReveal, reveal],
);
const handleMobileClick = useCallback(
(e: React.MouseEvent) => {
if (shouldBlur) {
handleRevealClick(e);
return;
}
setIsBottomSheetOpen(true);
},
[shouldBlur, handleRevealClick],
);
const handleMobileClick = useCallback(() => {
setIsBottomSheetOpen(true);
}, []);
const handleCloseBottomSheet = useCallback(() => {
setIsBottomSheetOpen(false);
}, []);
if (shouldBlock) {
return null;
}
const stickerImage = (
<img
src={stickerUrl}
alt={stickerRecord?.description || sticker.name}
className={clsx(styles.stickerImage, shouldBlur && matureStyles.matureStickerBlurred)}
className={styles.stickerImage}
width="160"
height="160"
data-flx="channel.message-attachments.sticker-item.sticker-image"
@@ -601,16 +578,13 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
</>
);
}
const renderHoverTooltip = () =>
shouldBlur ? (
sticker.name
) : (
<ExpressionHoverTooltipContent
displayName={sticker.name}
previewUrl={previewUrl}
data-flx="channel.message-attachments.sticker-item.expression-hover-tooltip-content"
/>
);
const renderHoverTooltip = () => (
<ExpressionHoverTooltipContent
displayName={sticker.name}
previewUrl={previewUrl}
data-flx="channel.message-attachments.sticker-item.expression-hover-tooltip-content"
/>
);
const renderInfoCard = ({onClose}: {onClose: () => void}) => (
<ExpressionInfoCard
kind="sticker"
@@ -624,7 +598,6 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
);
return (
<ExpressionInfoPopout
canOpenCard={!shouldBlur}
renderTooltip={renderHoverTooltip}
renderCard={renderInfoCard}
data-flx="channel.message-attachments.sticker-item.expression-info-popout"
@@ -635,7 +608,6 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
className={clsx(styles.stickerWrapper, styles.stickerWrapperInteractive)}
data-message-sticker="true"
onContextMenu={handleContextMenu}
onClick={handleRevealClick}
data-flx="channel.message-attachments.sticker-item.sticker-wrapper.reveal-click"
{...interactionHandlers}
>
@@ -19,11 +19,9 @@ import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import {ReactionTooltip} from '@app/features/messaging/components/popouts/ReactionTooltip';
import {ReactionImage} from '@app/features/messaging/components/ReactionImage';
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
import {useMessageReactions as useMessageReactionsSnapshot} from '@app/features/messaging/hooks/useMessageReactionStore';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {getEmojiName, getReactionKey, useEmojiURL} from '@app/features/messaging/utils/ReactionUtils';
import matureStyles from '@app/features/theme/styles/MatureBlur.module.css';
import {EmojiContextMenuItems} from '@app/features/ui/action_menu/items/EmojiContextMenuItems';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -137,7 +135,6 @@ const MessageReactionItem = observer(
};
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering: isHovering || tooltipHovering});
const {shouldBlur: reactionShouldBlur, shouldBlock: reactionShouldBlock} = useMatureMedia(false, message.channelId);
const variants = {
up: {y: -20, opacity: 0},
down: {y: 20, opacity: 0},
@@ -175,16 +172,14 @@ const MessageReactionItem = observer(
data-flx="channel.message-reactions.message-reaction-item.reaction-inner"
>
{emojiUrl ? (
reactionShouldBlock ? null : (
<ReactionImage
src={emojiUrl}
alt={emojiName}
aria-hidden={true}
draggable={false}
className={clsx('emoji', styles.emoji, reactionShouldBlur && matureStyles.matureBlurred)}
data-flx="channel.message-reactions.message-reaction-item.emoji"
/>
)
<ReactionImage
src={emojiUrl}
alt={emojiName}
aria-hidden={true}
draggable={false}
className={clsx('emoji', styles.emoji)}
data-flx="channel.message-reactions.message-reaction-item.emoji"
/>
) : null}
<div
className={styles.countWrapper}
@@ -42,7 +42,7 @@ const ToggleGroupSubmenu: React.FC<{group: ToggleGroup}> = observer(({group}) =>
data-flx="channel.channel-header-components.developer-tools-context-menu.toggle-group-submenu.checkbox-item"
>
{description ? (
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: project policy forbids the native title attribute, so aria-label carries the description on the developer-options row
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: project policy forbids the native title attribute, so aria-label holds the description on the developer-options row
<span
aria-label={translateDescriptor(i18n, description)}
data-flx="channel.channel-header-components.developer-tools-context-menu.toggle-group-submenu.span"
@@ -47,7 +47,7 @@ export const NOT_A_MEMBER_LONG_ENOUGH_DESCRIPTOR = msg({
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
export const ACCOUNT_LIMITED_DESCRIPTOR = msg({
message: 'Account limited',
message: 'Messaging paused',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
export const SEND_MESSAGES_DISABLED_DESCRIPTOR = msg({
@@ -11,6 +11,7 @@ import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils';
import UnicodeEmojis, {EMOJI_SPRITES} from '@app/features/expressions/utils/UnicodeEmojis';
import {loadImage} from '@app/features/messaging/utils/ImageCacheUtils';
import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector';
import {EmojiContextMenuItems} from '@app/features/ui/action_menu/items/EmojiContextMenuItems';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
@@ -25,9 +26,34 @@ type PickerEmojiImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
alt: string;
};
const PICKER_IMAGE_RETRY_LIMIT = 3;
const PickerEmojiImage = ({src, alt, ...props}: PickerEmojiImageProps) => {
const imageRef = useRef<HTMLImageElement | null>(null);
const hasLoadedRef = useRef(false);
const retriesRef = useRef(0);
const cancelRetryRef = useRef<(() => void) | null>(null);
useEffect(() => {
retriesRef.current = 0;
return () => {
cancelRetryRef.current?.();
cancelRetryRef.current = null;
};
}, [src]);
const handleError = () => {
if (retriesRef.current >= PICKER_IMAGE_RETRY_LIMIT) {
return;
}
retriesRef.current += 1;
cancelRetryRef.current?.();
cancelRetryRef.current = loadImage(src, () => {
cancelRetryRef.current = null;
const image = imageRef.current;
if (image != null && image.getAttribute('src') === src) {
image.src = src;
}
});
};
const handleLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
const view = event.currentTarget?.ownerDocument?.defaultView ?? window;
view.requestAnimationFrame(() => {
@@ -47,6 +73,7 @@ const PickerEmojiImage = ({src, alt, ...props}: PickerEmojiImageProps) => {
alt={alt}
className={hasLoadedRef.current ? styles.emojiImage : clsx(styles.emojiImage, styles.emojiImageLoading)}
onLoad={hasLoadedRef.current ? undefined : handleLoad}
onError={hasLoadedRef.current ? undefined : handleError}
/>
);
};
@@ -23,6 +23,11 @@ export interface DiscoveryGuild {
type DiscoveryGuildPayload = Omit<DiscoveryGuild, 'banner'> & {banner?: string | null};
export interface DiscoveryChannelPreview {
guild: {id: string; name: string; icon: string | null};
channel: {id: string; name: string | null; type: number};
}
interface DiscoveryCategoryCountPayload {
category_type: number;
count: number;
@@ -115,6 +120,11 @@ export async function getCategories(): Promise<Array<DiscoveryCategory>> {
return requestDiscoveryCategories();
}
export async function getChannelPreview(guildId: string, channelId: string): Promise<DiscoveryChannelPreview> {
const response = await http.get<DiscoveryChannelPreview>(Endpoints.DISCOVERY_CHANNEL_PREVIEW(guildId, channelId));
return response.body;
}
export async function joinGuild(guildId: string): Promise<void> {
await http.post(Endpoints.DISCOVERY_JOIN(guildId));
logger.info('Joined guild via discovery', {guildId});
@@ -112,10 +112,17 @@ function showJoinGuildErrorModal(error: unknown): void {
);
}
export async function joinDiscoveryGuild(guildId: string): Promise<boolean> {
export async function joinDiscoveryGuild(
guildId: string,
target?: {channelId: string; messageId?: string},
): Promise<boolean> {
try {
await DiscoveryCommands.joinGuild(guildId);
NavigationCommands.selectGuild(guildId);
if (target) {
NavigationCommands.selectChannel(guildId, target.channelId, target.messageId);
} else {
NavigationCommands.selectGuild(guildId);
}
return true;
} catch (error) {
showJoinGuildErrorModal(error);
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DiscoveryChannelPreview} from '@app/features/discovery/commands/DiscoveryCommands';
import * as DiscoveryCommands from '@app/features/discovery/commands/DiscoveryCommands';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {makeAutoObservable, observable, runInAction} from 'mobx';
type DiscoveryChannelPreviewEntry =
| {status: 'loading'}
| {status: 'ready'; preview: DiscoveryChannelPreview}
| {status: 'unavailable'};
function entryKey(guildId: string, channelId: string): string {
return `${guildId}:${channelId}`;
}
class DiscoveryChannelPreviews {
private entries = observable.map<string, DiscoveryChannelPreviewEntry>();
private discoveryDisabled = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get(guildId: string, channelId: string): DiscoveryChannelPreviewEntry | undefined {
return this.entries.get(entryKey(guildId, channelId));
}
request(guildId: string, channelId: string): void {
const key = entryKey(guildId, channelId);
if (this.discoveryDisabled || this.entries.has(key)) return;
this.entries.set(key, {status: 'loading'});
DiscoveryCommands.getChannelPreview(guildId, channelId).then(
(preview) => {
runInAction(() => this.entries.set(key, {status: 'ready', preview}));
},
(error: unknown) => {
const code = failureCode(error);
runInAction(() => {
if (code === APIErrorCodes.DISCOVERY_DISABLED) {
this.discoveryDisabled = true;
this.entries.set(key, {status: 'unavailable'});
} else if (code === APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE) {
this.entries.set(key, {status: 'unavailable'});
} else {
this.entries.delete(key);
}
});
},
);
}
}
export default new DiscoveryChannelPreviews();
@@ -157,7 +157,7 @@ describe('ExperimentAssignments response handling', () => {
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
});
it('accepts an envelope that carries no domain migration assignment', async () => {
it('accepts an envelope that has no domain migration assignment', async () => {
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined();
expect(lastScheduledDelayMs()).toBe(600_000);
@@ -387,7 +387,7 @@ describe('ExperimentAssignments lifecycle', () => {
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
});
it('does not carry the etag or the backoff of the previous session across a reset', async () => {
it('does not keep the etag or the backoff of the previous session across a reset', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
await adopt(CANARY_ENVELOPE);
vi.mocked(http.get).mockResolvedValue(reply(500, {message: '500: Internal Server Error'}));
@@ -23,17 +23,3 @@
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.sectionDescriptionMultiline {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.sectionDescriptionMultiline p {
margin-bottom: 0.5rem;
}
.sectionDescriptionMultiline p:last-child {
margin-bottom: 0;
}
@@ -284,27 +284,27 @@ const GuildModerationTab: React.FC<{guildId: string}> = observer(({guildId}) =>
<h3 className={styles.sectionTitle} data-flx="guild.guild-tabs.guild-moderation-tab.section-title">
<Trans>Member verification</Trans>
</h3>
<div
className={styles.sectionDescriptionMultiline}
data-flx="guild.guild-tabs.guild-moderation-tab.section-description-multiline"
<p
className={styles.sectionDescription}
data-flx="guild.guild-tabs.guild-moderation-tab.section-description--verification"
>
<p data-flx="guild.guild-tabs.guild-moderation-tab.p">
<Trans>Choose what members must have before they can post or DM community members.</Trans>
</p>
<p data-flx="guild.guild-tabs.guild-moderation-tab.p--2">
<Trans>
Choose what members must have before they can post or DM community members. Members with roles can
bypass these checks. For public spaces, we recommend enabling verification.
</Trans>
</p>
{isDiscoverable && (
<p
className={styles.sectionDescription}
style={{fontStyle: 'italic'}}
data-flx="guild.guild-tabs.guild-moderation-tab.section-description--verification-discovery"
>
<Trans>
Members with roles can bypass these checks. For public spaces, we recommend enabling verification.
Communities listed in Discovery require at least email verification. None cannot be selected while
Discovery is enabled.
</Trans>
</p>
{isDiscoverable && (
<p data-flx="guild.guild-tabs.guild-moderation-tab.p--3">
<Trans>
Communities listed in Discovery require at least email verification. None cannot be selected while
Discovery is enabled.
</Trans>
</p>
)}
</div>
)}
<Controller
name="verification_level"
control={form.control}
@@ -21,7 +21,7 @@ msgstr "... تشغيل الوضع المكثف. رائع!"
#. Button or menu action label in the channel and chat user message. Keep it concise.
#. Suffix after the message text in the forward modal preview when the forwarded message was edited.
#: src/features/channel/components/MessageAttachments.tsx:392
#: src/features/channel/components/MessageAttachments.tsx:390
#: src/features/channel/components/UserMessage.tsx:75
#: src/features/messaging/components/modals/ForwardMessagePreview.tsx:26
msgid "(edited)"
@@ -1019,8 +1019,8 @@ msgid "{count, plural, one {# image} other {# images}}"
msgstr "{count, plural, zero {# صورة} one {# صورة} two {# صورتين} few {# صور} many {# صورة} other {# صورة}}"
#: src/features/channel/components/ChannelSourcePreview.tsx:245
#: src/features/channel/components/direct_message/DMListItem.tsx:437
#: src/features/channel/components/direct_message/DMListItem.tsx:601
#: src/features/channel/components/direct_message/DMListItem.tsx:430
#: src/features/channel/components/direct_message/DMListItem.tsx:592
#: src/features/guild/components/bottomsheets/GuildHeaderBottomSheet.tsx:118
#: src/features/user/components/modals/MutualItemsSheet.tsx:159
#: src/features/user/components/modals/user_profile_modal/MutualGroupItem.tsx:26
@@ -1051,7 +1051,7 @@ msgid "{count, plural, one {# potential spammer message} other {# potential spam
msgstr "{count, plural, zero {# رسالة من مرسلين مزعجين محتملين} one {رسالة واحدة من مرسل مزعج محتمل} two {رسالتان من مرسلين مزعجين محتملين} few {# رسائل من مرسلين مزعجين محتملين} many {# رسالة من مرسلين مزعجين محتملين} other {# رسالة من مرسلين مزعجين محتملين}}"
#: src/features/app/components/shared/MessageReactionsContent.tsx:56
#: src/features/channel/components/MessageReactions.tsx:146
#: src/features/channel/components/MessageReactions.tsx:143
msgid "{count, plural, one {# reaction} other {# reactions}}"
msgstr "{count, plural, zero {# تفاعل} one {تفاعل واحد} two {تفاعلان} few {# تفاعلات} many {# تفاعلًا} other {# تفاعل}}"
@@ -1329,12 +1329,12 @@ msgid "{emojiName}, {reactionCountText}"
msgstr "{emojiName}، {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:50
#: src/features/channel/components/MessageReactions.tsx:48
msgid "{emojiName}: {reactionCountText}"
msgstr "{emojiName}: {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}, {actionText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:55
#: src/features/channel/components/MessageReactions.tsx:53
msgid "{emojiName}: {reactionCountText}, {actionText}"
msgstr "{emojiName}: {reactionCountText}، {actionText}"
@@ -2215,8 +2215,8 @@ msgstr "لا يزال <0>{username}</0> في المجموعة. حاول مرة
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the community and channel the message came from.
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the conversation the message came from.
#: src/features/channel/components/MessageAttachments.tsx:243
#: src/features/channel/components/MessageAttachments.tsx:299
#: src/features/channel/components/MessageAttachments.tsx:241
#: src/features/channel/components/MessageAttachments.tsx:297
msgid "<0>Forwarded from</0>{sourceInfo}"
msgstr "<0>مُعاد توجيهها من</0>{sourceInfo}"
@@ -2787,11 +2787,6 @@ msgstr "بيانات الحساب"
msgid "Account exists to spam, scam, or abuse the platform."
msgstr "الحساب موجود لإرسال الرسائل المزعجة أو الاحتيال أو إساءة استخدام المنصة."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Account limited"
msgstr "الحساب محدود"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/AccountSecuritySections.ts:124
msgid "Account removal"
@@ -6730,7 +6725,7 @@ msgid "Categories"
msgstr "الفئات"
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:84
#: src/features/app/components/layout/ChannelItem.tsx:83
msgid "category"
msgstr "الفئة"
@@ -7200,7 +7195,7 @@ msgstr "تُطبّق التغييرات فورًا على العميل قيد ا
#. Fallback channel-name placeholder used inside the textarea message hint when no name is available. Lowercase.
#. Lowercase generic channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:92
#: src/features/app/components/layout/ChannelItem.tsx:91
#: src/features/channel/components/channel_textarea/shared.ts:12
msgid "channel"
msgstr "القناة"
@@ -7768,9 +7763,9 @@ msgstr "اختر ما يمكن أن يفعله {clientLabel} في مجتمعك.
msgid "Choose what friends see in their active now panel"
msgstr "اختر ما يراه أصدقاؤك في لوحة \"نشط الآن\""
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:292
msgid "Choose what members must have before they can post or DM community members."
msgstr "اختر ما يجب أن يستوفيه الأعضاء قبل أن يتمكنوا من النشر أو مراسلة أعضاء المجتمع مباشرة."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:291
msgid "Choose what members must have before they can post or DM community members. Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "اختر ما يجب أن يمتلكه الأعضاء قبل أن يتمكنوا من النشر أو إرسال رسائل مباشرة لأعضاء المجتمع. يمكن للأعضاء الذين لديهم أدوار تجاوز هذه الضوابط. بالنسبة للمساحات العامة، نوصي بتمكين التحقق."
#. Scope option (radio) that lets the user pick conversation kinds and exclude specific communities.
#: src/features/user/components/modals/tabs/privacy_safety_tab/data_request_modal/DataRequestModal.tsx:71
@@ -8296,7 +8291,7 @@ msgid "Close account"
msgstr "إغلاق الحساب"
#. Accessible label and tooltip for the close-DM button on a DM list row.
#: src/features/channel/components/direct_message/DMListItem.tsx:83
#: src/features/channel/components/direct_message/DMListItem.tsx:82
msgid "Close direct message with {displayName}"
msgstr "إغلاق الرسالة المباشرة مع {displayName}"
@@ -8505,7 +8500,7 @@ msgstr "طي الرسائل غير المقروءة في {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for a collapsed category.
#: src/features/app/components/layout/ChannelItem.tsx:100
#: src/features/app/components/layout/ChannelItem.tsx:99
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:85
msgid "collapsed"
msgstr "مطوي"
@@ -8643,7 +8638,7 @@ msgstr "يتم إخفاء المجتمعات والقنوات التي لا يم
msgid "Communities listed in Discovery are required to scan messages from all members. This setting cannot be changed while Discovery is enabled."
msgstr "المجتمعات المدرجة في الاكتشاف ملزمة بفحص رسائل جميع الأعضاء. لا يمكن تغيير هذا الإعداد أثناء تفعيل الاكتشاف."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:301
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:302
msgid "Communities listed in Discovery require at least email verification. None cannot be selected while Discovery is enabled."
msgstr "تتطلب المجتمعات المدرجة في الاكتشاف التحقق من البريد الإلكتروني على الأقل. لا يمكن اختيار \"بلا\" أثناء تفعيل الاكتشاف."
@@ -9149,7 +9144,7 @@ msgid "Connect with {providerName}"
msgstr "الاتصال بـ{providerName}"
#. Lowercase state label used inside channel-list accessible text for the current voice channel.
#: src/features/app/components/layout/ChannelItem.tsx:116
#: src/features/app/components/layout/ChannelItem.tsx:115
msgid "connected"
msgstr "متصل"
@@ -12678,7 +12673,7 @@ msgid "DM"
msgstr "رسالة مباشرة"
#. Very short badge shown in place of an avatar on a favorited direct-message row. Must fit about 2-3 characters; abbreviate rather than translate in full.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:70
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:68
msgctxt "channel-badge"
msgid "DM"
msgstr "ر.م"
@@ -13039,7 +13034,7 @@ msgid "Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at
msgstr "جارٍ تنزيل تحديث تطبيق سطح المكتب: {percent}% ({formatBytes} / {formatBytes2} بسرعة {formatBytes3}/ث)"
#. Short desktop updater status label.
#: src/features/app/state/Updater.ts:46
#: src/features/app/state/Updater.ts:48
msgid "Downloading desktop update…"
msgstr "جارٍ تنزيل تحديث تطبيق سطح المكتب…"
@@ -13294,7 +13289,7 @@ msgstr "تعديل المرفق"
#. Action that opens the edit-category modal.
#. Action that opens the edit-category modal.
#. Tooltip and accessible label for the category settings button in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:120
#: src/features/app/components/layout/ChannelItem.tsx:119
#: src/features/ui/action_menu/items/CategoryMenuData.tsx:85
#: src/features/ui/action_menu/items/CategoryMenuItems.tsx:62
msgid "Edit category"
@@ -13754,7 +13749,7 @@ msgid "Empty edit"
msgstr "تعديل فارغ"
#. Short label in the app layout favorites channel list content.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:77
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:75
msgid "Empty favorites"
msgstr "المفضلة فارغة"
@@ -14608,7 +14603,7 @@ msgstr "توسيع الرسائل غير المقروءة في {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for an expanded category.
#: src/features/app/components/layout/ChannelItem.tsx:104
#: src/features/app/components/layout/ChannelItem.tsx:103
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:81
msgid "expanded"
msgstr "موسّعة"
@@ -15849,7 +15844,7 @@ msgstr "إعادة توجيه الوسائط"
msgid "Forward message"
msgstr "إعادة توجيه الرسالة"
#: src/features/channel/components/MessageAttachments.tsx:362
#: src/features/channel/components/MessageAttachments.tsx:360
msgid "Forwarded"
msgstr "مُعاد توجيهها"
@@ -18624,7 +18619,7 @@ msgstr "إخفاء الدعوات"
#. Action label that opens the invite flow.
#. Button or menu action label in the guild header popout. Keep it concise.
#. Tooltip and accessible label for the invite button shown beside a channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:124
#: src/features/app/components/layout/ChannelItem.tsx:123
#: src/features/guild/components/popouts/GuildHeaderPopout.tsx:58
#: src/features/ui/action_menu/items/GuildMenuData.tsx:84
msgid "Invite members"
@@ -20849,10 +20844,6 @@ msgstr "تتم إزالة الأعضاء عند عدم الاتصال بالإن
msgid "Members with \"{pinMessagesPermissionLabel}\" can pin messages for everyone."
msgstr "يمكن للأعضاء الذين لديهم إذن \"{pinMessagesPermissionLabel}\" تثبيت الرسائل للجميع."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:295
msgid "Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "يمكن للأعضاء ذوي الأدوار تجاوز هذه الفحوصات. للمساحات العامة، نوصي بتمكين التحقق."
#. Helper text under the Allow mentions switch in the role editor. {mentionEveryonePermissionLabel} is the localized name of the Mention Everyone permission and should match its label exactly.
#: src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleEditor.tsx:47
msgid "Members with the \"{mentionEveryonePermissionLabel}\" permission can always mention roles, regardless of this setting."
@@ -21305,10 +21296,25 @@ msgstr "يتم نسخ الرسائل المنشورة في قنوات الإعل
msgid "Messages you can bookmark"
msgstr "الرسائل التي يمكنك تمييزها"
#. Title of the error modal shown when an action fails because messaging is paused on the current account until the user completes one quick step described in an email. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Messaging is paused"
msgstr "إرسال الرسائل متوقف مؤقتًا"
#. Notice shown in place of the message composer, and as an error, when messaging is paused on the current account until the user completes one quick step described in an email. While paused, the account cannot post, react, join communities or change its profile. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Messaging is paused on your account. Check your email for a quick step to continue."
msgstr "إرسال الرسائل متوقف مؤقتًا في حسابك. ستجد في بريدك الإلكتروني خطوة سريعة واحدة للمتابعة."
#: src/features/channel/components/barriers/BarrierComponents.tsx:274
msgid "Messaging is temporarily paused in this community."
msgstr "تم إيقاف المراسلة مؤقتًا في هذا المجتمع."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Messaging paused"
msgstr "إرسال الرسائل متوقف مؤقتًا"
#. Column header for saved billing payment methods.
#: src/features/app/components/dialogs/components/plutonium/PaymentMethodList.tsx:65
msgid "Method"
@@ -22051,10 +22057,10 @@ msgstr "كتم صوت البث"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel is muted.
#. Screen-reader fragment announcing that the DM list row is muted. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:112
#: src/features/app/components/layout/ChannelItem.tsx:111
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:62
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:39
#: src/features/channel/components/direct_message/DMListItem.tsx:75
#: src/features/channel/components/direct_message/DMListItem.tsx:74
msgid "muted"
msgstr "مكتوم"
@@ -23984,7 +23990,7 @@ msgstr "فتح تفاصيل القناة لـ {channelName}"
#. Context-menu action that opens a voice channel's embedded chat view without connecting to voice.
#. Tooltip and accessible label for the chat icon shown beside a voice channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:128
#: src/features/app/components/layout/ChannelItem.tsx:127
#: src/features/ui/action_menu/items/ChannelMenuData.tsx:156
msgid "Open chat"
msgstr "فتح الدردشة"
@@ -25232,7 +25238,7 @@ msgid "Ping: {latency}ms"
msgstr "البينج: {latency} مللي ثانية"
#. Screen-reader fragment announcing that the DM list row is pinned. Lowercase fragment.
#: src/features/channel/components/direct_message/DMListItem.tsx:79
#: src/features/channel/components/direct_message/DMListItem.tsx:78
msgid "pinned"
msgstr "مثبتة"
@@ -25619,12 +25625,12 @@ msgid "Press Continue to use your passkey."
msgstr "اضغط على متابعة لاستخدام مفتاح المرور الخاص بك."
#. Label in the channel and chat message reactions.
#: src/features/channel/components/MessageReactions.tsx:46
#: src/features/channel/components/MessageReactions.tsx:44
msgid "press to add reaction"
msgstr "اضغط لإضافة رد فعل"
#. Label in the channel and chat message reactions. Keep the tone plain and specific.
#: src/features/channel/components/MessageReactions.tsx:42
#: src/features/channel/components/MessageReactions.tsx:40
msgid "press to remove reaction"
msgstr "اضغط لإزالة رد الفعل"
@@ -29530,10 +29536,10 @@ msgstr "نص قابل للتحديد"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text for the selected channel.
#. Screen-reader suffix announcing that the DM list row is the active selection. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:96
#: src/features/app/components/layout/ChannelItem.tsx:95
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:54
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:35
#: src/features/channel/components/direct_message/DMListItem.tsx:67
#: src/features/channel/components/direct_message/DMListItem.tsx:66
msgid "selected"
msgstr "محدد"
@@ -29841,7 +29847,7 @@ msgid "Sent"
msgstr "تم الإرسال"
#. DM list row preview text when the most recent message has only attachments.
#: src/features/channel/components/direct_message/DMListItem.tsx:63
#: src/features/channel/components/direct_message/DMListItem.tsx:62
msgid "Sent an attachment"
msgstr "أرسل مرفقًا"
@@ -36228,11 +36234,11 @@ msgstr "تم إلغاء تثبيت المحادثة الجماعية"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel has unread messages.
#. Screen-reader fragment announcing that the DM list row has unread messages. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:108
#: src/features/app/components/layout/ChannelItem.tsx:107
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:89
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:58
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:27
#: src/features/channel/components/direct_message/DMListItem.tsx:71
#: src/features/channel/components/direct_message/DMListItem.tsx:70
msgid "unread"
msgstr "غير مقروءة"
@@ -38120,7 +38126,7 @@ msgstr "مكالمة صوتية. {statusText}."
#. Generic channel-type token used inside a wider delete confirmation string.
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:88
#: src/features/app/components/layout/ChannelItem.tsx:87
#: src/features/ui/action_menu/items/ChannelMenuItems.tsx:79
msgid "voice channel"
msgstr "قناة صوتية"
@@ -39163,7 +39169,7 @@ msgstr "أمس في {timeString}"
#. First-person prefix used in DM list row preview when the current user is the message author.
#: src/features/app/components/layout/MobileBottomNav.tsx:153
#: src/features/channel/components/direct_message/DMListItem.tsx:59
#: src/features/channel/components/direct_message/DMListItem.tsx:58
msgid "You"
msgstr "أنت"
@@ -40067,16 +40073,6 @@ msgstr "حسابك"
msgid "Your account has been linked successfully. You can now close this tab and return to the app."
msgstr "تم ربط حسابك بنجاح. يمكنك الآن إغلاق علامة التبويب هذه والعودة إلى التطبيق."
#. Title of the error modal shown when an action fails because the current account is limited.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Your account is limited"
msgstr "حسابك محدود"
#. Notice shown in place of the message composer, and as an error, when the current account is limited and cannot post, react, join communities or change its profile.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Your account is limited. Check your email for how to lift it."
msgstr "حسابك محدود. تحقق من بريدك الإلكتروني لمعرفة كيفية رفعه."
#: src/features/app/components/dialogs/components/UnclaimedAccountAlert.tsx:26
msgid "Your account is not yet claimed. Without an email and password, you won't be able to sign in from other devices and you could lose access to your account. Claim your account now to secure it."
msgstr "لم تتم المطالبة بحسابك بعد. بدون بريد إلكتروني وكلمة مرور، لن تتمكن من تسجيل الدخول من أجهزة أخرى وقد تفقد الوصول إلى حسابك. طالب بحسابك الآن لتأمينه."
@@ -1413,9 +1413,6 @@
{
"msgid": "Accepted"
},
{
"msgid": "Account limited"
},
{
"msgid": "Add a Klipy API key to enable GIF search at runtime."
},
@@ -1773,6 +1770,9 @@
{
"msgid": "Choose the premium model"
},
{
"msgid": "Choose what members must have before they can post or DM community members. Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
},
{
"msgid": "Choose where its published messages should go. You can unfollow any time in Community settings → Webhooks."
},
@@ -2382,6 +2382,15 @@
{
"msgid": "Messages you can bookmark"
},
{
"msgid": "Messaging is paused"
},
{
"msgid": "Messaging is paused on your account. Check your email for a quick step to continue."
},
{
"msgid": "Messaging paused"
},
{
"msgid": "Microphone ({deviceLabel})"
},
@@ -3705,12 +3714,6 @@
{
"msgid": "YouTube enrichment"
},
{
"msgid": "Your account is limited"
},
{
"msgid": "Your account is limited. Check your email for how to lift it."
},
{
"msgid": "Your account will no longer be protected by two-factor authentication when you sign in with your password. You'll also lose elevated permissions in servers that require two-factor authentication."
},
@@ -21,7 +21,7 @@ msgstr "... включиш сбития режим. Супер!"
#. Button or menu action label in the channel and chat user message. Keep it concise.
#. Suffix after the message text in the forward modal preview when the forwarded message was edited.
#: src/features/channel/components/MessageAttachments.tsx:392
#: src/features/channel/components/MessageAttachments.tsx:390
#: src/features/channel/components/UserMessage.tsx:75
#: src/features/messaging/components/modals/ForwardMessagePreview.tsx:26
msgid "(edited)"
@@ -1019,8 +1019,8 @@ msgid "{count, plural, one {# image} other {# images}}"
msgstr "{count, plural, one {# изображение} other {# изображения}}"
#: src/features/channel/components/ChannelSourcePreview.tsx:245
#: src/features/channel/components/direct_message/DMListItem.tsx:437
#: src/features/channel/components/direct_message/DMListItem.tsx:601
#: src/features/channel/components/direct_message/DMListItem.tsx:430
#: src/features/channel/components/direct_message/DMListItem.tsx:592
#: src/features/guild/components/bottomsheets/GuildHeaderBottomSheet.tsx:118
#: src/features/user/components/modals/MutualItemsSheet.tsx:159
#: src/features/user/components/modals/user_profile_modal/MutualGroupItem.tsx:26
@@ -1051,7 +1051,7 @@ msgid "{count, plural, one {# potential spammer message} other {# potential spam
msgstr "{count, plural, one {# потенциално спам съобщение} other {# потенциални спам съобщения}}"
#: src/features/app/components/shared/MessageReactionsContent.tsx:56
#: src/features/channel/components/MessageReactions.tsx:146
#: src/features/channel/components/MessageReactions.tsx:143
msgid "{count, plural, one {# reaction} other {# reactions}}"
msgstr "{count, plural, one {# реакция} other {# реакции}}"
@@ -1329,12 +1329,12 @@ msgid "{emojiName}, {reactionCountText}"
msgstr "{emojiName}, {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:50
#: src/features/channel/components/MessageReactions.tsx:48
msgid "{emojiName}: {reactionCountText}"
msgstr "{emojiName}: {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}, {actionText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:55
#: src/features/channel/components/MessageReactions.tsx:53
msgid "{emojiName}: {reactionCountText}, {actionText}"
msgstr "{emojiName}: {reactionCountText}, {actionText}"
@@ -2215,8 +2215,8 @@ msgstr "<0>{username}</0> все още е в групата. Опитай от
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the community and channel the message came from.
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the conversation the message came from.
#: src/features/channel/components/MessageAttachments.tsx:243
#: src/features/channel/components/MessageAttachments.tsx:299
#: src/features/channel/components/MessageAttachments.tsx:241
#: src/features/channel/components/MessageAttachments.tsx:297
msgid "<0>Forwarded from</0>{sourceInfo}"
msgstr "<0>Препратено от</0>{sourceInfo}"
@@ -2787,11 +2787,6 @@ msgstr "Данни за акаунта"
msgid "Account exists to spam, scam, or abuse the platform."
msgstr "Акаунтът съществува за спам, измами или злоупотреба с платформата."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Account limited"
msgstr "Акаунтът е ограничен"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/AccountSecuritySections.ts:124
msgid "Account removal"
@@ -6730,7 +6725,7 @@ msgid "Categories"
msgstr "Категории"
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:84
#: src/features/app/components/layout/ChannelItem.tsx:83
msgid "category"
msgstr "категория"
@@ -7200,7 +7195,7 @@ msgstr "Промените се прилагат незабавно към ак
#. Fallback channel-name placeholder used inside the textarea message hint when no name is available. Lowercase.
#. Lowercase generic channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:92
#: src/features/app/components/layout/ChannelItem.tsx:91
#: src/features/channel/components/channel_textarea/shared.ts:12
msgid "channel"
msgstr "канал"
@@ -7768,9 +7763,9 @@ msgstr "Избери какво може да прави {clientLabel} в общ
msgid "Choose what friends see in their active now panel"
msgstr "Избери какво виждат приятелите ти в панела „Активни сега“"
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:292
msgid "Choose what members must have before they can post or DM community members."
msgstr "Избери какво трябва да имат членовете, преди да могат да публикуват или да изпращат DM на членове на общността."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:291
msgid "Choose what members must have before they can post or DM community members. Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Изберете какво трябва да имат членовете, преди да могат да публикуват или да изпращат лични съобщения до членове на общността. Членовете с роли могат да заобиколят тези проверки. За публични пространства препоръчваме да активирате верификация."
#. Scope option (radio) that lets the user pick conversation kinds and exclude specific communities.
#: src/features/user/components/modals/tabs/privacy_safety_tab/data_request_modal/DataRequestModal.tsx:71
@@ -8296,7 +8291,7 @@ msgid "Close account"
msgstr "Закриване на акаунт"
#. Accessible label and tooltip for the close-DM button on a DM list row.
#: src/features/channel/components/direct_message/DMListItem.tsx:83
#: src/features/channel/components/direct_message/DMListItem.tsx:82
msgid "Close direct message with {displayName}"
msgstr "Затваряне на директното съобщение с {displayName}"
@@ -8505,7 +8500,7 @@ msgstr "Свиване на непрочетените съобщения за {
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for a collapsed category.
#: src/features/app/components/layout/ChannelItem.tsx:100
#: src/features/app/components/layout/ChannelItem.tsx:99
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:85
msgid "collapsed"
msgstr "свито"
@@ -8643,7 +8638,7 @@ msgstr "Общности и канали, където не можете да у
msgid "Communities listed in Discovery are required to scan messages from all members. This setting cannot be changed while Discovery is enabled."
msgstr "Общностите, включени в Откриване, трябва да сканират съобщенията от всички членове. Тази настройка не може да се променя, докато Откриване е включено."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:301
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:302
msgid "Communities listed in Discovery require at least email verification. None cannot be selected while Discovery is enabled."
msgstr "Общностите, включени в Откриване, изискват поне потвърждение на имейл. Докато Откриване е включено, не може да се избере „Няма“."
@@ -9149,7 +9144,7 @@ msgid "Connect with {providerName}"
msgstr "Свързване с {providerName}"
#. Lowercase state label used inside channel-list accessible text for the current voice channel.
#: src/features/app/components/layout/ChannelItem.tsx:116
#: src/features/app/components/layout/ChannelItem.tsx:115
msgid "connected"
msgstr "свързан"
@@ -12678,7 +12673,7 @@ msgid "DM"
msgstr "DM"
#. Very short badge shown in place of an avatar on a favorited direct-message row. Must fit about 2-3 characters; abbreviate rather than translate in full.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:70
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:68
msgctxt "channel-badge"
msgid "DM"
msgstr "DM"
@@ -13039,7 +13034,7 @@ msgid "Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at
msgstr "Изтегляне на актуализация за настолното приложение: {percent}% ({formatBytes} / {formatBytes2} при {formatBytes3}/сек)"
#. Short desktop updater status label.
#: src/features/app/state/Updater.ts:46
#: src/features/app/state/Updater.ts:48
msgid "Downloading desktop update…"
msgstr "Изтегляне на актуализация за настолното приложение…"
@@ -13294,7 +13289,7 @@ msgstr "Редактиране на прикачен файл"
#. Action that opens the edit-category modal.
#. Action that opens the edit-category modal.
#. Tooltip and accessible label for the category settings button in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:120
#: src/features/app/components/layout/ChannelItem.tsx:119
#: src/features/ui/action_menu/items/CategoryMenuData.tsx:85
#: src/features/ui/action_menu/items/CategoryMenuItems.tsx:62
msgid "Edit category"
@@ -13754,7 +13749,7 @@ msgid "Empty edit"
msgstr "Празна редакция"
#. Short label in the app layout favorites channel list content.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:77
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:75
msgid "Empty favorites"
msgstr "Няма любими"
@@ -14608,7 +14603,7 @@ msgstr "Разгъване на непрочетените съобщения з
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for an expanded category.
#: src/features/app/components/layout/ChannelItem.tsx:104
#: src/features/app/components/layout/ChannelItem.tsx:103
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:81
msgid "expanded"
msgstr "разгънато"
@@ -15849,7 +15844,7 @@ msgstr "Препращане на медия"
msgid "Forward message"
msgstr "Препращане на съобщение"
#: src/features/channel/components/MessageAttachments.tsx:362
#: src/features/channel/components/MessageAttachments.tsx:360
msgid "Forwarded"
msgstr "Препратено"
@@ -18624,7 +18619,7 @@ msgstr "Маскиране на покани"
#. Action label that opens the invite flow.
#. Button or menu action label in the guild header popout. Keep it concise.
#. Tooltip and accessible label for the invite button shown beside a channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:124
#: src/features/app/components/layout/ChannelItem.tsx:123
#: src/features/guild/components/popouts/GuildHeaderPopout.tsx:58
#: src/features/ui/action_menu/items/GuildMenuData.tsx:84
msgid "Invite members"
@@ -20849,10 +20844,6 @@ msgstr "Членовете се премахват, когато излязат
msgid "Members with \"{pinMessagesPermissionLabel}\" can pin messages for everyone."
msgstr "Членовете с „{pinMessagesPermissionLabel}“ могат да закачат съобщения за всички."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:295
msgid "Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Членовете с роли могат да заобиколят тези проверки. За публични пространства препоръчваме да включиш потвърждението."
#. Helper text under the Allow mentions switch in the role editor. {mentionEveryonePermissionLabel} is the localized name of the Mention Everyone permission and should match its label exactly.
#: src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleEditor.tsx:47
msgid "Members with the \"{mentionEveryonePermissionLabel}\" permission can always mention roles, regardless of this setting."
@@ -21305,10 +21296,25 @@ msgstr "Съобщенията, публикувани в тези канали
msgid "Messages you can bookmark"
msgstr "Съобщения, които можете да запазите"
#. Title of the error modal shown when an action fails because messaging is paused on the current account until the user completes one quick step described in an email. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Messaging is paused"
msgstr "Изпращането на съобщения е на пауза"
#. Notice shown in place of the message composer, and as an error, when messaging is paused on the current account until the user completes one quick step described in an email. While paused, the account cannot post, react, join communities or change its profile. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Messaging is paused on your account. Check your email for a quick step to continue."
msgstr "Изпращането на съобщения от акаунта ти е на пауза. Погледни имейла си: остава само една бърза стъпка, за да продължиш."
#: src/features/channel/components/barriers/BarrierComponents.tsx:274
msgid "Messaging is temporarily paused in this community."
msgstr "Изпращането на съобщения е временно спряно в тази общност."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Messaging paused"
msgstr "Изпращането на съобщения е на пауза"
#. Column header for saved billing payment methods.
#: src/features/app/components/dialogs/components/plutonium/PaymentMethodList.tsx:65
msgid "Method"
@@ -22051,10 +22057,10 @@ msgstr "Изключване на звука на стрийма"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel is muted.
#. Screen-reader fragment announcing that the DM list row is muted. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:112
#: src/features/app/components/layout/ChannelItem.tsx:111
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:62
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:39
#: src/features/channel/components/direct_message/DMListItem.tsx:75
#: src/features/channel/components/direct_message/DMListItem.tsx:74
msgid "muted"
msgstr "заглушено"
@@ -23984,7 +23990,7 @@ msgstr "Отваряне на подробности за канала „{chann
#. Context-menu action that opens a voice channel's embedded chat view without connecting to voice.
#. Tooltip and accessible label for the chat icon shown beside a voice channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:128
#: src/features/app/components/layout/ChannelItem.tsx:127
#: src/features/ui/action_menu/items/ChannelMenuData.tsx:156
msgid "Open chat"
msgstr "Отваряне на чата"
@@ -25232,7 +25238,7 @@ msgid "Ping: {latency}ms"
msgstr "Пинг: {latency} мс"
#. Screen-reader fragment announcing that the DM list row is pinned. Lowercase fragment.
#: src/features/channel/components/direct_message/DMListItem.tsx:79
#: src/features/channel/components/direct_message/DMListItem.tsx:78
msgid "pinned"
msgstr "закачено"
@@ -25619,12 +25625,12 @@ msgid "Press Continue to use your passkey."
msgstr "Натисни „Продължи“, за да използваш своя ключ за достъп."
#. Label in the channel and chat message reactions.
#: src/features/channel/components/MessageReactions.tsx:46
#: src/features/channel/components/MessageReactions.tsx:44
msgid "press to add reaction"
msgstr "натисни, за да добавиш реакция"
#. Label in the channel and chat message reactions. Keep the tone plain and specific.
#: src/features/channel/components/MessageReactions.tsx:42
#: src/features/channel/components/MessageReactions.tsx:40
msgid "press to remove reaction"
msgstr "натисни, за да премахнеш реакцията"
@@ -29530,10 +29536,10 @@ msgstr "Избираем текст"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text for the selected channel.
#. Screen-reader suffix announcing that the DM list row is the active selection. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:96
#: src/features/app/components/layout/ChannelItem.tsx:95
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:54
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:35
#: src/features/channel/components/direct_message/DMListItem.tsx:67
#: src/features/channel/components/direct_message/DMListItem.tsx:66
msgid "selected"
msgstr "избрано"
@@ -29841,7 +29847,7 @@ msgid "Sent"
msgstr "Изпратено"
#. DM list row preview text when the most recent message has only attachments.
#: src/features/channel/components/direct_message/DMListItem.tsx:63
#: src/features/channel/components/direct_message/DMListItem.tsx:62
msgid "Sent an attachment"
msgstr "Изпрати прикачен файл"
@@ -36228,11 +36234,11 @@ msgstr "Груповото DM е откачено"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel has unread messages.
#. Screen-reader fragment announcing that the DM list row has unread messages. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:108
#: src/features/app/components/layout/ChannelItem.tsx:107
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:89
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:58
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:27
#: src/features/channel/components/direct_message/DMListItem.tsx:71
#: src/features/channel/components/direct_message/DMListItem.tsx:70
msgid "unread"
msgstr "непрочетени"
@@ -38120,7 +38126,7 @@ msgstr "Гласово повикване. {statusText}."
#. Generic channel-type token used inside a wider delete confirmation string.
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:88
#: src/features/app/components/layout/ChannelItem.tsx:87
#: src/features/ui/action_menu/items/ChannelMenuItems.tsx:79
msgid "voice channel"
msgstr "гласов канал"
@@ -39163,7 +39169,7 @@ msgstr "Вчера в {timeString}"
#. First-person prefix used in DM list row preview when the current user is the message author.
#: src/features/app/components/layout/MobileBottomNav.tsx:153
#: src/features/channel/components/direct_message/DMListItem.tsx:59
#: src/features/channel/components/direct_message/DMListItem.tsx:58
msgid "You"
msgstr "Ти"
@@ -40067,16 +40073,6 @@ msgstr "Твоят акаунт"
msgid "Your account has been linked successfully. You can now close this tab and return to the app."
msgstr "Акаунтът ти е свързан успешно. Вече можеш да затвориш този раздел и да се върнеш в приложението."
#. Title of the error modal shown when an action fails because the current account is limited.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Your account is limited"
msgstr "Вашият акаунт е с ограничени възможности"
#. Notice shown in place of the message composer, and as an error, when the current account is limited and cannot post, react, join communities or change its profile.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Your account is limited. Check your email for how to lift it."
msgstr "Вашият акаунт е ограничен. Проверете имейла си за информация как да го премахнете."
#: src/features/app/components/dialogs/components/UnclaimedAccountAlert.tsx:26
msgid "Your account is not yet claimed. Without an email and password, you won't be able to sign in from other devices and you could lose access to your account. Claim your account now to secure it."
msgstr "Акаунтът ти още не е заявен. Без имейл и парола няма да можеш да влизаш от други устройства и може да загубиш достъп до акаунта си. Заяви акаунта си сега, за да го защитиш."
@@ -21,7 +21,7 @@ msgstr "... zapnout zhuštěný režim. Super!"
#. Button or menu action label in the channel and chat user message. Keep it concise.
#. Suffix after the message text in the forward modal preview when the forwarded message was edited.
#: src/features/channel/components/MessageAttachments.tsx:392
#: src/features/channel/components/MessageAttachments.tsx:390
#: src/features/channel/components/UserMessage.tsx:75
#: src/features/messaging/components/modals/ForwardMessagePreview.tsx:26
msgid "(edited)"
@@ -1019,8 +1019,8 @@ msgid "{count, plural, one {# image} other {# images}}"
msgstr "{count, plural, one {# obrázek} few {# obrázky} many {# obrázku} other {# obrázků}}"
#: src/features/channel/components/ChannelSourcePreview.tsx:245
#: src/features/channel/components/direct_message/DMListItem.tsx:437
#: src/features/channel/components/direct_message/DMListItem.tsx:601
#: src/features/channel/components/direct_message/DMListItem.tsx:430
#: src/features/channel/components/direct_message/DMListItem.tsx:592
#: src/features/guild/components/bottomsheets/GuildHeaderBottomSheet.tsx:118
#: src/features/user/components/modals/MutualItemsSheet.tsx:159
#: src/features/user/components/modals/user_profile_modal/MutualGroupItem.tsx:26
@@ -1051,7 +1051,7 @@ msgid "{count, plural, one {# potential spammer message} other {# potential spam
msgstr "{count, plural, one {# zpráva od možného spammera} few {# zprávy od možného spammera} many {# zprávy od možného spammera} other {# zpráv od možného spammera}}"
#: src/features/app/components/shared/MessageReactionsContent.tsx:56
#: src/features/channel/components/MessageReactions.tsx:146
#: src/features/channel/components/MessageReactions.tsx:143
msgid "{count, plural, one {# reaction} other {# reactions}}"
msgstr "{count, plural, one {# reakce} few {# reakce} many {# reakce} other {# reakcí}}"
@@ -1329,12 +1329,12 @@ msgid "{emojiName}, {reactionCountText}"
msgstr "{emojiName}, {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:50
#: src/features/channel/components/MessageReactions.tsx:48
msgid "{emojiName}: {reactionCountText}"
msgstr "{emojiName}: {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}, {actionText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:55
#: src/features/channel/components/MessageReactions.tsx:53
msgid "{emojiName}: {reactionCountText}, {actionText}"
msgstr "{emojiName}: {reactionCountText}, {actionText}"
@@ -2215,8 +2215,8 @@ msgstr "<0>{username}</0> je stále ve skupině. Zkuste to znovu za chvíli."
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the community and channel the message came from.
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the conversation the message came from.
#: src/features/channel/components/MessageAttachments.tsx:243
#: src/features/channel/components/MessageAttachments.tsx:299
#: src/features/channel/components/MessageAttachments.tsx:241
#: src/features/channel/components/MessageAttachments.tsx:297
msgid "<0>Forwarded from</0>{sourceInfo}"
msgstr "<0>Přeposláno z</0>{sourceInfo}"
@@ -2787,11 +2787,6 @@ msgstr "Údaje o účtu"
msgid "Account exists to spam, scam, or abuse the platform."
msgstr "Účet slouží k rozesílání spamu, podvodům nebo zneužívání platformy."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Account limited"
msgstr "Účet omezen"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/AccountSecuritySections.ts:124
msgid "Account removal"
@@ -6730,7 +6725,7 @@ msgid "Categories"
msgstr "Kategorie"
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:84
#: src/features/app/components/layout/ChannelItem.tsx:83
msgid "category"
msgstr "kategorie"
@@ -7200,7 +7195,7 @@ msgstr "Změny se okamžitě projeví v běžícím klientovi."
#. Fallback channel-name placeholder used inside the textarea message hint when no name is available. Lowercase.
#. Lowercase generic channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:92
#: src/features/app/components/layout/ChannelItem.tsx:91
#: src/features/channel/components/channel_textarea/shared.ts:12
msgid "channel"
msgstr "kanál"
@@ -7768,9 +7763,9 @@ msgstr "Vyberte, co může {clientLabel} dělat ve vaší komunitě. Zrušte za
msgid "Choose what friends see in their active now panel"
msgstr "Vyberte, co přátelé uvidí na panelu „Právě aktivní“"
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:292
msgid "Choose what members must have before they can post or DM community members."
msgstr "Zvolte, co musí mít členové, než budou moct přispívat nebo posílat přímé zprávy členům komunity."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:291
msgid "Choose what members must have before they can post or DM community members. Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Vyberte, co musí mít členové, než budou moci posílat příspěvky nebo soukromé zprávy členům komunity. Členové s rolemi mohou tyto kontroly přeskočit. Pro veřejné prostory doporučujeme povolit ověření."
#. Scope option (radio) that lets the user pick conversation kinds and exclude specific communities.
#: src/features/user/components/modals/tabs/privacy_safety_tab/data_request_modal/DataRequestModal.tsx:71
@@ -8296,7 +8291,7 @@ msgid "Close account"
msgstr "Zavřít účet"
#. Accessible label and tooltip for the close-DM button on a DM list row.
#: src/features/channel/components/direct_message/DMListItem.tsx:83
#: src/features/channel/components/direct_message/DMListItem.tsx:82
msgid "Close direct message with {displayName}"
msgstr "Zavřít soukromou konverzaci s uživatelem {displayName}"
@@ -8505,7 +8500,7 @@ msgstr "Sbalit nepřečtené zprávy pro {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for a collapsed category.
#: src/features/app/components/layout/ChannelItem.tsx:100
#: src/features/app/components/layout/ChannelItem.tsx:99
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:85
msgid "collapsed"
msgstr "sbaleno"
@@ -8643,7 +8638,7 @@ msgstr "Komunity a kanály, kde nemůžete spravovat webhooky, jsou skryté."
msgid "Communities listed in Discovery are required to scan messages from all members. This setting cannot be changed while Discovery is enabled."
msgstr "Komunity uvedené v Objevování musí kontrolovat zprávy všech členů. Toto nastavení nelze změnit, dokud je Objevování zapnuté."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:301
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:302
msgid "Communities listed in Discovery require at least email verification. None cannot be selected while Discovery is enabled."
msgstr "Komunity uvedené v Objevování vyžadují alespoň ověření e-mailu. Pokud je Objevování zapnuté, nelze vybrat možnost „Žádné“."
@@ -9149,7 +9144,7 @@ msgid "Connect with {providerName}"
msgstr "Propojit s {providerName}"
#. Lowercase state label used inside channel-list accessible text for the current voice channel.
#: src/features/app/components/layout/ChannelItem.tsx:116
#: src/features/app/components/layout/ChannelItem.tsx:115
msgid "connected"
msgstr "připojeno"
@@ -12678,7 +12673,7 @@ msgid "DM"
msgstr "DM"
#. Very short badge shown in place of an avatar on a favorited direct-message row. Must fit about 2-3 characters; abbreviate rather than translate in full.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:70
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:68
msgctxt "channel-badge"
msgid "DM"
msgstr "SZ"
@@ -13039,7 +13034,7 @@ msgid "Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at
msgstr "Stahování aktualizace desktopové aplikace: {percent} % ({formatBytes} / {formatBytes2} rychlostí {formatBytes3}/s)"
#. Short desktop updater status label.
#: src/features/app/state/Updater.ts:46
#: src/features/app/state/Updater.ts:48
msgid "Downloading desktop update…"
msgstr "Stahování aktualizace desktopové aplikace…"
@@ -13294,7 +13289,7 @@ msgstr "Upravit přílohu"
#. Action that opens the edit-category modal.
#. Action that opens the edit-category modal.
#. Tooltip and accessible label for the category settings button in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:120
#: src/features/app/components/layout/ChannelItem.tsx:119
#: src/features/ui/action_menu/items/CategoryMenuData.tsx:85
#: src/features/ui/action_menu/items/CategoryMenuItems.tsx:62
msgid "Edit category"
@@ -13754,7 +13749,7 @@ msgid "Empty edit"
msgstr "Prázdná úprava"
#. Short label in the app layout favorites channel list content.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:77
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:75
msgid "Empty favorites"
msgstr "Oblíbené položky jsou prázdné"
@@ -14608,7 +14603,7 @@ msgstr "Rozbalit nepřečtené zprávy pro {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for an expanded category.
#: src/features/app/components/layout/ChannelItem.tsx:104
#: src/features/app/components/layout/ChannelItem.tsx:103
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:81
msgid "expanded"
msgstr "rozbaleno"
@@ -15849,7 +15844,7 @@ msgstr "Přeposlat médium"
msgid "Forward message"
msgstr "Přeposlat zprávu"
#: src/features/channel/components/MessageAttachments.tsx:362
#: src/features/channel/components/MessageAttachments.tsx:360
msgid "Forwarded"
msgstr "Přeposláno"
@@ -18624,7 +18619,7 @@ msgstr "Maskování pozvánek"
#. Action label that opens the invite flow.
#. Button or menu action label in the guild header popout. Keep it concise.
#. Tooltip and accessible label for the invite button shown beside a channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:124
#: src/features/app/components/layout/ChannelItem.tsx:123
#: src/features/guild/components/popouts/GuildHeaderPopout.tsx:58
#: src/features/ui/action_menu/items/GuildMenuData.tsx:84
msgid "Invite members"
@@ -20849,10 +20844,6 @@ msgstr "Členové jsou odebráni, když jsou offline, pokud jim není přidělen
msgid "Members with \"{pinMessagesPermissionLabel}\" can pin messages for everyone."
msgstr "Členové s oprávněním „{pinMessagesPermissionLabel}“ mohou připínat zprávy pro všechny."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:295
msgid "Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Členové s rolemi mohou tyto kontroly obejít. Pro veřejné prostory doporučujeme povolit ověření."
#. Helper text under the Allow mentions switch in the role editor. {mentionEveryonePermissionLabel} is the localized name of the Mention Everyone permission and should match its label exactly.
#: src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleEditor.tsx:47
msgid "Members with the \"{mentionEveryonePermissionLabel}\" permission can always mention roles, regardless of this setting."
@@ -21305,10 +21296,25 @@ msgstr "Zprávy publikované v těchto kanálech pro oznámení se kopírují do
msgid "Messages you can bookmark"
msgstr "Zprávy, které si můžete uložit do záložek"
#. Title of the error modal shown when an action fails because messaging is paused on the current account until the user completes one quick step described in an email. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Messaging is paused"
msgstr "Posílání zpráv je na pauze"
#. Notice shown in place of the message composer, and as an error, when messaging is paused on the current account until the user completes one quick step described in an email. While paused, the account cannot post, react, join communities or change its profile. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Messaging is paused on your account. Check your email for a quick step to continue."
msgstr "U vašeho účtu je posílání zpráv na pauze. Podívejte se do e-mailu: stačí jeden rychlý krok a můžete pokračovat."
#: src/features/channel/components/barriers/BarrierComponents.tsx:274
msgid "Messaging is temporarily paused in this community."
msgstr "Zasílání zpráv je v této komunitě dočasně pozastaveno."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Messaging paused"
msgstr "Posílání zpráv je na pauze"
#. Column header for saved billing payment methods.
#: src/features/app/components/dialogs/components/plutonium/PaymentMethodList.tsx:65
msgid "Method"
@@ -22051,10 +22057,10 @@ msgstr "Vypnout zvuk streamu"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel is muted.
#. Screen-reader fragment announcing that the DM list row is muted. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:112
#: src/features/app/components/layout/ChannelItem.tsx:111
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:62
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:39
#: src/features/channel/components/direct_message/DMListItem.tsx:75
#: src/features/channel/components/direct_message/DMListItem.tsx:74
msgid "muted"
msgstr "ztlumeno"
@@ -23984,7 +23990,7 @@ msgstr "Otevřít podrobnosti kanálu {channelName}"
#. Context-menu action that opens a voice channel's embedded chat view without connecting to voice.
#. Tooltip and accessible label for the chat icon shown beside a voice channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:128
#: src/features/app/components/layout/ChannelItem.tsx:127
#: src/features/ui/action_menu/items/ChannelMenuData.tsx:156
msgid "Open chat"
msgstr "Otevřít chat"
@@ -25232,7 +25238,7 @@ msgid "Ping: {latency}ms"
msgstr "Ping: {latency} ms"
#. Screen-reader fragment announcing that the DM list row is pinned. Lowercase fragment.
#: src/features/channel/components/direct_message/DMListItem.tsx:79
#: src/features/channel/components/direct_message/DMListItem.tsx:78
msgid "pinned"
msgstr "připnuto"
@@ -25619,12 +25625,12 @@ msgid "Press Continue to use your passkey."
msgstr "Stisknutím tlačítka Pokračovat použijete svůj přístupový klíč."
#. Label in the channel and chat message reactions.
#: src/features/channel/components/MessageReactions.tsx:46
#: src/features/channel/components/MessageReactions.tsx:44
msgid "press to add reaction"
msgstr "klepnutím přidáte reakci"
#. Label in the channel and chat message reactions. Keep the tone plain and specific.
#: src/features/channel/components/MessageReactions.tsx:42
#: src/features/channel/components/MessageReactions.tsx:40
msgid "press to remove reaction"
msgstr "klepnutím odeberete reakci"
@@ -29530,10 +29536,10 @@ msgstr "Text, který lze označit"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text for the selected channel.
#. Screen-reader suffix announcing that the DM list row is the active selection. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:96
#: src/features/app/components/layout/ChannelItem.tsx:95
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:54
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:35
#: src/features/channel/components/direct_message/DMListItem.tsx:67
#: src/features/channel/components/direct_message/DMListItem.tsx:66
msgid "selected"
msgstr "vybráno"
@@ -29841,7 +29847,7 @@ msgid "Sent"
msgstr "Odesláno"
#. DM list row preview text when the most recent message has only attachments.
#: src/features/channel/components/direct_message/DMListItem.tsx:63
#: src/features/channel/components/direct_message/DMListItem.tsx:62
msgid "Sent an attachment"
msgstr "Odeslal(a) přílohu"
@@ -36228,11 +36234,11 @@ msgstr "Skupinová konverzace odepnuta"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel has unread messages.
#. Screen-reader fragment announcing that the DM list row has unread messages. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:108
#: src/features/app/components/layout/ChannelItem.tsx:107
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:89
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:58
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:27
#: src/features/channel/components/direct_message/DMListItem.tsx:71
#: src/features/channel/components/direct_message/DMListItem.tsx:70
msgid "unread"
msgstr "nepřečteno"
@@ -38120,7 +38126,7 @@ msgstr "Hlasový hovor. {statusText}."
#. Generic channel-type token used inside a wider delete confirmation string.
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:88
#: src/features/app/components/layout/ChannelItem.tsx:87
#: src/features/ui/action_menu/items/ChannelMenuItems.tsx:79
msgid "voice channel"
msgstr "hlasový kanál"
@@ -39163,7 +39169,7 @@ msgstr "Včera v {timeString}"
#. First-person prefix used in DM list row preview when the current user is the message author.
#: src/features/app/components/layout/MobileBottomNav.tsx:153
#: src/features/channel/components/direct_message/DMListItem.tsx:59
#: src/features/channel/components/direct_message/DMListItem.tsx:58
msgid "You"
msgstr "Vy"
@@ -40067,16 +40073,6 @@ msgstr "Váš účet"
msgid "Your account has been linked successfully. You can now close this tab and return to the app."
msgstr "Váš účet byl úspěšně propojen. Nyní můžete zavřít tuto kartu a vrátit se do aplikace."
#. Title of the error modal shown when an action fails because the current account is limited.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Your account is limited"
msgstr "Váš účet má omezení"
#. Notice shown in place of the message composer, and as an error, when the current account is limited and cannot post, react, join communities or change its profile.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Your account is limited. Check your email for how to lift it."
msgstr "Váš účet je omezený. Zkontrolujte si e-mail, jak omezení zrušit."
#: src/features/app/components/dialogs/components/UnclaimedAccountAlert.tsx:26
msgid "Your account is not yet claimed. Without an email and password, you won't be able to sign in from other devices and you could lose access to your account. Claim your account now to secure it."
msgstr "Registrace vašeho účtu zatím není dokončená. Bez e-mailu a hesla se nebudete moci přihlásit z jiných zařízení a mohli byste ztratit přístup ke svému účtu. Dokončete registraci svého účtu a zabezpečte ho."
@@ -21,7 +21,7 @@ msgstr "... slå kompakt visning til. Fedt!"
#. Button or menu action label in the channel and chat user message. Keep it concise.
#. Suffix after the message text in the forward modal preview when the forwarded message was edited.
#: src/features/channel/components/MessageAttachments.tsx:392
#: src/features/channel/components/MessageAttachments.tsx:390
#: src/features/channel/components/UserMessage.tsx:75
#: src/features/messaging/components/modals/ForwardMessagePreview.tsx:26
msgid "(edited)"
@@ -1019,8 +1019,8 @@ msgid "{count, plural, one {# image} other {# images}}"
msgstr "{count, plural, one {# billede} other {# billeder}}"
#: src/features/channel/components/ChannelSourcePreview.tsx:245
#: src/features/channel/components/direct_message/DMListItem.tsx:437
#: src/features/channel/components/direct_message/DMListItem.tsx:601
#: src/features/channel/components/direct_message/DMListItem.tsx:430
#: src/features/channel/components/direct_message/DMListItem.tsx:592
#: src/features/guild/components/bottomsheets/GuildHeaderBottomSheet.tsx:118
#: src/features/user/components/modals/MutualItemsSheet.tsx:159
#: src/features/user/components/modals/user_profile_modal/MutualGroupItem.tsx:26
@@ -1051,7 +1051,7 @@ msgid "{count, plural, one {# potential spammer message} other {# potential spam
msgstr "{count, plural, one {# besked fra en mulig spammer} other {# beskeder fra mulige spammere}}"
#: src/features/app/components/shared/MessageReactionsContent.tsx:56
#: src/features/channel/components/MessageReactions.tsx:146
#: src/features/channel/components/MessageReactions.tsx:143
msgid "{count, plural, one {# reaction} other {# reactions}}"
msgstr "{count, plural, one {# reaktion} other {# reaktioner}}"
@@ -1329,12 +1329,12 @@ msgid "{emojiName}, {reactionCountText}"
msgstr "{emojiName}, {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:50
#: src/features/channel/components/MessageReactions.tsx:48
msgid "{emojiName}: {reactionCountText}"
msgstr "{emojiName}: {reactionCountText}"
#. Short label in the channel and chat message reactions. Keep it concise. Preserve {emojiName}, {reactionCountText}, {actionText}; they are inserted by code.
#: src/features/channel/components/MessageReactions.tsx:55
#: src/features/channel/components/MessageReactions.tsx:53
msgid "{emojiName}: {reactionCountText}, {actionText}"
msgstr "{emojiName}: {reactionCountText}, {actionText}"
@@ -2215,8 +2215,8 @@ msgstr "<0>{username}</0> er stadig i gruppen. Prøv igen om et øjeblik."
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the community and channel the message came from.
#. Attribution line on a forwarded message, above the forwarded content. sourceInfo is the icon and name of the conversation the message came from.
#: src/features/channel/components/MessageAttachments.tsx:243
#: src/features/channel/components/MessageAttachments.tsx:299
#: src/features/channel/components/MessageAttachments.tsx:241
#: src/features/channel/components/MessageAttachments.tsx:297
msgid "<0>Forwarded from</0>{sourceInfo}"
msgstr "<0>Videresendt fra</0>{sourceInfo}"
@@ -2787,11 +2787,6 @@ msgstr "Kontodata"
msgid "Account exists to spam, scam, or abuse the platform."
msgstr "Kontoen eksisterer for at spamme, svindle eller misbruge platformen."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Account limited"
msgstr "Konto begrænset"
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
#: src/features/user/components/settings_utils/section_registry/AccountSecuritySections.ts:124
msgid "Account removal"
@@ -6730,7 +6725,7 @@ msgid "Categories"
msgstr "Kategorier"
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:84
#: src/features/app/components/layout/ChannelItem.tsx:83
msgid "category"
msgstr "kategori"
@@ -7200,7 +7195,7 @@ msgstr "Ændringer anvendes med det samme i den aktive klient."
#. Fallback channel-name placeholder used inside the textarea message hint when no name is available. Lowercase.
#. Lowercase generic channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:92
#: src/features/app/components/layout/ChannelItem.tsx:91
#: src/features/channel/components/channel_textarea/shared.ts:12
msgid "channel"
msgstr "kanal"
@@ -7768,9 +7763,9 @@ msgstr "Vælg, hvad {clientLabel} kan gøre i dit fællesskab. Fjern markeringen
msgid "Choose what friends see in their active now panel"
msgstr "Vælg, hvad venner ser i panelet Aktiv nu"
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:292
msgid "Choose what members must have before they can post or DM community members."
msgstr "Vælg, hvilke krav medlemmer skal opfylde, før de kan skrive beskeder eller sende direkte beskeder til medlemmer af fællesskabet."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:291
msgid "Choose what members must have before they can post or DM community members. Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Vælg, hvad medlemmer skal have, før de kan poste eller sende direkte beskeder til community-medlemmer. Medlemmer med roller kan omgå disse tjek. For offentlige rum anbefaler vi at aktivere verificering."
#. Scope option (radio) that lets the user pick conversation kinds and exclude specific communities.
#: src/features/user/components/modals/tabs/privacy_safety_tab/data_request_modal/DataRequestModal.tsx:71
@@ -8296,7 +8291,7 @@ msgid "Close account"
msgstr "Luk konto"
#. Accessible label and tooltip for the close-DM button on a DM list row.
#: src/features/channel/components/direct_message/DMListItem.tsx:83
#: src/features/channel/components/direct_message/DMListItem.tsx:82
msgid "Close direct message with {displayName}"
msgstr "Luk den direkte samtale med {displayName}"
@@ -8505,7 +8500,7 @@ msgstr "Skjul ulæste beskeder for {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for a collapsed category.
#: src/features/app/components/layout/ChannelItem.tsx:100
#: src/features/app/components/layout/ChannelItem.tsx:99
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:85
msgid "collapsed"
msgstr "skjult"
@@ -8643,7 +8638,7 @@ msgstr "Fællesskaber og kanaler, hvor du ikke kan administrere webhooks, er skj
msgid "Communities listed in Discovery are required to scan messages from all members. This setting cannot be changed while Discovery is enabled."
msgstr "Fællesskaber i Opdag skal scanne beskeder fra alle medlemmer. Denne indstilling kan ikke ændres, mens Opdag er aktiveret."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:301
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:302
msgid "Communities listed in Discovery require at least email verification. None cannot be selected while Discovery is enabled."
msgstr "Fællesskaber i Opdag kræver mindst en bekræftet e-mailadresse. \"Ingen\" kan ikke vælges, mens Opdag er aktiveret."
@@ -9149,7 +9144,7 @@ msgid "Connect with {providerName}"
msgstr "Opret forbindelse med {providerName}"
#. Lowercase state label used inside channel-list accessible text for the current voice channel.
#: src/features/app/components/layout/ChannelItem.tsx:116
#: src/features/app/components/layout/ChannelItem.tsx:115
msgid "connected"
msgstr "forbundet"
@@ -12678,7 +12673,7 @@ msgid "DM"
msgstr "DM"
#. Very short badge shown in place of an avatar on a favorited direct-message row. Must fit about 2-3 characters; abbreviate rather than translate in full.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:70
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:68
msgctxt "channel-badge"
msgid "DM"
msgstr "DM"
@@ -13039,7 +13034,7 @@ msgid "Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at
msgstr "Downloader opdatering til computerappen: {percent}% ({formatBytes} / {formatBytes2} med {formatBytes3}/s)"
#. Short desktop updater status label.
#: src/features/app/state/Updater.ts:46
#: src/features/app/state/Updater.ts:48
msgid "Downloading desktop update…"
msgstr "Downloader opdatering til computerappen…"
@@ -13294,7 +13289,7 @@ msgstr "Rediger vedhæftet fil"
#. Action that opens the edit-category modal.
#. Action that opens the edit-category modal.
#. Tooltip and accessible label for the category settings button in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:120
#: src/features/app/components/layout/ChannelItem.tsx:119
#: src/features/ui/action_menu/items/CategoryMenuData.tsx:85
#: src/features/ui/action_menu/items/CategoryMenuItems.tsx:62
msgid "Edit category"
@@ -13754,7 +13749,7 @@ msgid "Empty edit"
msgstr "Tom redigering"
#. Short label in the app layout favorites channel list content.
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:77
#: src/features/app/components/layout/FavoritesChannelListContent.tsx:75
msgid "Empty favorites"
msgstr "Tomme favoritter"
@@ -14608,7 +14603,7 @@ msgstr "Udvid ulæste beskeder for {channelHeading}"
#. Lowercase screen-reader fragment in the sidebar navigation guild folder item.
#. Lowercase state label used inside channel-list accessible text for an expanded category.
#: src/features/app/components/layout/ChannelItem.tsx:104
#: src/features/app/components/layout/ChannelItem.tsx:103
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:81
msgid "expanded"
msgstr "udvidet"
@@ -15849,7 +15844,7 @@ msgstr "Videresend medie"
msgid "Forward message"
msgstr "Videresend besked"
#: src/features/channel/components/MessageAttachments.tsx:362
#: src/features/channel/components/MessageAttachments.tsx:360
msgid "Forwarded"
msgstr "Videresendt"
@@ -18624,7 +18619,7 @@ msgstr "Maskering af invitationer"
#. Action label that opens the invite flow.
#. Button or menu action label in the guild header popout. Keep it concise.
#. Tooltip and accessible label for the invite button shown beside a channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:124
#: src/features/app/components/layout/ChannelItem.tsx:123
#: src/features/guild/components/popouts/GuildHeaderPopout.tsx:58
#: src/features/ui/action_menu/items/GuildMenuData.tsx:84
msgid "Invite members"
@@ -20849,10 +20844,6 @@ msgstr "Medlemmer fjernes, når de er offline, medmindre de får en rolle."
msgid "Members with \"{pinMessagesPermissionLabel}\" can pin messages for everyone."
msgstr "Medlemmer med \"{pinMessagesPermissionLabel}\" kan fastgøre beskeder for alle."
#: src/features/guild/components/modals/guild_tabs/GuildModerationTab.tsx:295
msgid "Members with roles can bypass these checks. For public spaces, we recommend enabling verification."
msgstr "Medlemmer med roller kan omgå disse tjek. Til offentlige rum anbefaler vi at aktivere bekræftelse."
#. Helper text under the Allow mentions switch in the role editor. {mentionEveryonePermissionLabel} is the localized name of the Mention Everyone permission and should match its label exactly.
#: src/features/guild/components/modals/guild_tabs/guild_roles_tab/RoleEditor.tsx:47
msgid "Members with the \"{mentionEveryonePermissionLabel}\" permission can always mention roles, regardless of this setting."
@@ -21305,10 +21296,25 @@ msgstr "Beskeder, der udgives i disse annonceringskanaler, kopieres til dine kan
msgid "Messages you can bookmark"
msgstr "Beskeder du kan bogmærke"
#. Title of the error modal shown when an action fails because messaging is paused on the current account until the user completes one quick step described in an email. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Messaging is paused"
msgstr "Beskeder er sat på pause"
#. Notice shown in place of the message composer, and as an error, when messaging is paused on the current account until the user completes one quick step described in an email. While paused, the account cannot post, react, join communities or change its profile. Keep the tone calm and friendly.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Messaging is paused on your account. Check your email for a quick step to continue."
msgstr "Beskeder er sat på pause på din konto. Tjek din e-mail for et hurtigt trin, så du kan fortsætte."
#: src/features/channel/components/barriers/BarrierComponents.tsx:274
msgid "Messaging is temporarily paused in this community."
msgstr "Afsendelse af beskeder er midlertidigt sat på pause i dette fællesskab."
#. Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.
#: src/features/channel/components/channel_header_components/developer_tools/OptionPresets.ts:49
msgid "Messaging paused"
msgstr "Beskeder er sat på pause"
#. Column header for saved billing payment methods.
#: src/features/app/components/dialogs/components/plutonium/PaymentMethodList.tsx:65
msgid "Method"
@@ -22051,10 +22057,10 @@ msgstr "Slå lyd fra for stream"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel is muted.
#. Screen-reader fragment announcing that the DM list row is muted. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:112
#: src/features/app/components/layout/ChannelItem.tsx:111
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:62
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:39
#: src/features/channel/components/direct_message/DMListItem.tsx:75
#: src/features/channel/components/direct_message/DMListItem.tsx:74
msgid "muted"
msgstr "sat på lydløs"
@@ -23984,7 +23990,7 @@ msgstr "Åbn kanaloplysninger for {channelName}"
#. Context-menu action that opens a voice channel's embedded chat view without connecting to voice.
#. Tooltip and accessible label for the chat icon shown beside a voice channel in the channel list.
#: src/features/app/components/layout/ChannelItem.tsx:128
#: src/features/app/components/layout/ChannelItem.tsx:127
#: src/features/ui/action_menu/items/ChannelMenuData.tsx:156
msgid "Open chat"
msgstr "Åbn chat"
@@ -25232,7 +25238,7 @@ msgid "Ping: {latency}ms"
msgstr "Ping: {latency} ms"
#. Screen-reader fragment announcing that the DM list row is pinned. Lowercase fragment.
#: src/features/channel/components/direct_message/DMListItem.tsx:79
#: src/features/channel/components/direct_message/DMListItem.tsx:78
msgid "pinned"
msgstr "fastgjort"
@@ -25619,12 +25625,12 @@ msgid "Press Continue to use your passkey."
msgstr "Tryk på Fortsæt for at bruge din adgangsnøgle."
#. Label in the channel and chat message reactions.
#: src/features/channel/components/MessageReactions.tsx:46
#: src/features/channel/components/MessageReactions.tsx:44
msgid "press to add reaction"
msgstr "tryk for at tilføje reaktion"
#. Label in the channel and chat message reactions. Keep the tone plain and specific.
#: src/features/channel/components/MessageReactions.tsx:42
#: src/features/channel/components/MessageReactions.tsx:40
msgid "press to remove reaction"
msgstr "tryk for at fjerne reaktion"
@@ -29530,10 +29536,10 @@ msgstr "Tekst, der kan markeres"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text for the selected channel.
#. Screen-reader suffix announcing that the DM list row is the active selection. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:96
#: src/features/app/components/layout/ChannelItem.tsx:95
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:54
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:35
#: src/features/channel/components/direct_message/DMListItem.tsx:67
#: src/features/channel/components/direct_message/DMListItem.tsx:66
msgid "selected"
msgstr "valgt"
@@ -29841,7 +29847,7 @@ msgid "Sent"
msgstr "Sendt"
#. DM list row preview text when the most recent message has only attachments.
#: src/features/channel/components/direct_message/DMListItem.tsx:63
#: src/features/channel/components/direct_message/DMListItem.tsx:62
msgid "Sent an attachment"
msgstr "Sendte en vedhæftet fil"
@@ -36228,11 +36234,11 @@ msgstr "Fastgørelse af gruppe-DM fjernet"
#. Lowercase screen-reader fragment in the sidebar navigation guild list item.
#. Lowercase state label used inside channel-list accessible text when a channel has unread messages.
#. Screen-reader fragment announcing that the DM list row has unread messages. Lowercase fragment.
#: src/features/app/components/layout/ChannelItem.tsx:108
#: src/features/app/components/layout/ChannelItem.tsx:107
#: src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx:89
#: src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx:58
#: src/features/app/components/layout/sidebar_nav/UseGuildListItemState.ts:27
#: src/features/channel/components/direct_message/DMListItem.tsx:71
#: src/features/channel/components/direct_message/DMListItem.tsx:70
msgid "unread"
msgstr "ulæst"
@@ -38120,7 +38126,7 @@ msgstr "Taleopkald. {statusText}."
#. Generic channel-type token used inside a wider delete confirmation string.
#. Lowercase channel type label used inside channel-list accessible text.
#: src/features/app/components/layout/ChannelItem.tsx:88
#: src/features/app/components/layout/ChannelItem.tsx:87
#: src/features/ui/action_menu/items/ChannelMenuItems.tsx:79
msgid "voice channel"
msgstr "talekanal"
@@ -39163,7 +39169,7 @@ msgstr "I går kl. {timeString}"
#. First-person prefix used in DM list row preview when the current user is the message author.
#: src/features/app/components/layout/MobileBottomNav.tsx:153
#: src/features/channel/components/direct_message/DMListItem.tsx:59
#: src/features/channel/components/direct_message/DMListItem.tsx:58
msgid "You"
msgstr "Dig"
@@ -40067,16 +40073,6 @@ msgstr "Din konto"
msgid "Your account has been linked successfully. You can now close this tab and return to the app."
msgstr "Din konto er blevet forbundet. Du kan nu lukke denne fane og vende tilbage til appen."
#. Title of the error modal shown when an action fails because the current account is limited.
#: src/features/user/utils/AccountLimitUtils.ts:15
msgid "Your account is limited"
msgstr "Din konto er begrænset"
#. Notice shown in place of the message composer, and as an error, when the current account is limited and cannot post, react, join communities or change its profile.
#: src/features/user/utils/AccountLimitUtils.ts:10
msgid "Your account is limited. Check your email for how to lift it."
msgstr "Din konto er begrænset. Tjek din e-mail for at se, hvordan du fjerner begrænsningen."
#: src/features/app/components/dialogs/components/UnclaimedAccountAlert.tsx:26
msgid "Your account is not yet claimed. Without an email and password, you won't be able to sign in from other devices and you could lose access to your account. Claim your account now to secure it."
msgstr "Du har endnu ikke fuldført oprettelsen af din konto. Uden en e-mail og adgangskode kan du ikke logge ind fra andre enheder, og du risikerer at miste adgangen til din konto. Fuldfør kontooprettelsen nu for at sikre den."

Some files were not shown because too many files have changed in this diff Show More