fix(app,api): kick reasons, blocked counts and locale fallbacks (#3334)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:40:08 +02:00
1 parent a5c1362f4a
commit 4db13b0375
157 files changed
+3080 -1995

No files matched your search

@@ -78,6 +78,26 @@ describe('Admin guild routes', () => {
expect(result.guild.name).toBe(renamed);
expect(result.guild.features).toContain('VERIFIED');
});
test('PATCH /admin/guilds/{guild_id} returns the summary fields only when the body sets content warning fields', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:settings']);
const guild = await createGuild(harness, admin.token, `Warning Guild ${Date.now()}`);
const result = await createBuilder<AdminGuildUpdate>(harness, `${admin.token}`)
.patch(`/admin/guilds/${guild.id}`)
.body({nsfw: true, content_warning_level: 1, content_warning_text: 'Graphic content'})
.expect(HTTP_STATUS.OK)
.execute();
expect(Object.keys(result.guild).sort()).toEqual([
'banner',
'features',
'icon',
'id',
'member_count',
'name',
'nsfw_level',
'owner_id',
]);
});
test('PATCH /admin/guilds/{guild_id} requires the ACL selected by every supplied field', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:update:settings']);
+1 -1
View File
@@ -358,7 +358,7 @@ export function AuthController(app: HonoApp) {
OpenAPI({
operationId: 'revert_email_change',
summary: 'Revert email change',
responseSchema: AuthLoginResponse,
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
@@ -197,7 +197,7 @@ export class AuthRequestService {
if ('registration_pending_approval' in result) {
return result;
}
return await this.toAuthLoginResponse(result);
return await this.toAuthTokenResponse(result);
}
async login({
@@ -257,13 +257,13 @@ export class AuthRequestService {
};
}
async revertEmailChange({data, request}: AuthRevertEmailChangeRequest): Promise<AuthLoginResponse> {
async revertEmailChange({data, request}: AuthRevertEmailChangeRequest): Promise<AuthTokenWithUserIdResponse> {
const result = await AuthEmailRevert.revertEmailChange(this.apiContext, {
token: data.token,
password: data.password,
request,
});
return await this.toAuthLoginResponse(result);
return await this.toAuthTokenResponse(result);
}
getAuthSessions(userId: UserID, currentSessionIdHash?: Uint8Array): Promise<AuthSessionsResponse> {
@@ -198,6 +198,7 @@ describe('Email revert flow', () => {
.execute();
expect(revertResp.token.length).toBeGreaterThan(0);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
expect(Object.keys(revertResp).sort()).toEqual(['token', 'user', 'user_id']);
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const user = await createBuilder<UserPrivateResponse>(harness, revertResp.token).get('/users/@me').execute();
expect(user.email).toBe(account.email);
@@ -85,6 +85,7 @@ describe('Auth registration', () => {
});
expect(reg.token.length).toBeGreaterThan(0);
expect(reg.user_id.length).toBeGreaterThan(0);
expect(Object.keys(reg).sort()).toEqual(['token', 'user', 'user_id']);
});
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
@@ -84,6 +84,7 @@ describe('Auth registration policy', () => {
.execute();
expect(registration.registration_pending_approval).toBe(true);
expect(registration.user_id.length).toBeGreaterThan(0);
expect(Object.keys(registration).sort()).toEqual(['registration_pending_approval', 'user_id']);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: body.email, password: body.password})
@@ -7,6 +7,7 @@ import {
type ContentI18nKey,
} from '@app/api/content_i18n/ContentI18nMessages';
import {identityLocale} from '@fluxer/i18n/src/normalization/IdentityLocale';
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
import type {MessageArgsForTemplate} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
import {validateMessageTemplateVariables} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
@@ -30,7 +31,7 @@ const contentI18n = createStaticI18n<ContentI18nKey, string, ContentI18nRuntimeV
validateVariables: (_key, template, variables) => validateMessageTemplateVariables(template, variables),
},
(template, variables, mf) => {
return String(mf.compile(template)(variables));
return String(compileMessage(mf, template)(variables));
},
);
+35 -41
View File
@@ -217,7 +217,7 @@
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthLoginResponse"}}}
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthTokenWithUserIdResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -11364,6 +11364,13 @@
"required": true,
"schema": {"description": "The ID of the harvest request", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the harvest request"
},
{
"name": "token",
"in": "query",
"required": true,
"schema": {"description": "The signed download token from the harvest download URL", "type": "string"},
"description": "The signed download token from the harvest download URL"
}
]
}
@@ -32650,31 +32657,6 @@
"AuthRegisterResponse": {
"anyOf": [
{"$ref": "#/components/schemas/AuthTokenWithUserIdResponse"},
{
"type": "object",
"properties": {
"mfa": {
"type": "boolean",
"const": true,
"description": "Indicates MFA is required to complete authentication"
},
"ticket": {"type": "string", "description": "MFA ticket to use when completing MFA verification"},
"allowed_methods": {
"maxItems": 10,
"type": "array",
"items": {"type": "string"},
"description": "List of allowed MFA methods"
},
"totp": {"type": "boolean", "description": "Whether TOTP authenticator MFA is available"},
"webauthn": {"type": "boolean", "description": "Whether WebAuthn security key MFA is available"},
"backup_codes": {
"type": "boolean",
"description": "Whether the account has at least one unconsumed backup code"
}
},
"required": ["mfa", "ticket", "allowed_methods", "totp", "webauthn", "backup_codes"],
"additionalProperties": false
},
{"$ref": "#/components/schemas/AuthRegistrationPendingApprovalResponse"}
]
},
@@ -35490,7 +35472,7 @@
},
"provider": {
"description": "The provider of the embed (e.g., YouTube, Twitter)",
"anyOf": [{"$ref": "#/components/schemas/EmbedAuthorResponse"}, {"type": "null"}]
"anyOf": [{"$ref": "#/components/schemas/EmbedProviderResponse"}, {"type": "null"}]
},
"video": {
"description": "The video of the embed",
@@ -35618,7 +35600,7 @@
},
"provider": {
"description": "The provider of the embed (e.g., YouTube, Twitter)",
"anyOf": [{"$ref": "#/components/schemas/EmbedAuthorResponse"}, {"type": "null"}]
"anyOf": [{"$ref": "#/components/schemas/EmbedProviderResponse"}, {"type": "null"}]
},
"video": {
"description": "The video of the embed",
@@ -35677,20 +35659,12 @@
"required": ["url", "flags"],
"additionalProperties": false
},
"EmbedAuthorResponse": {
"EmbedProviderResponse": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The name of the author"},
"name": {"type": "string", "description": "The name of the provider"},
"url": {
"description": "The URL of the author",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
},
"icon_url": {
"description": "The URL of the author icon",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
},
"proxy_icon_url": {
"description": "The proxied URL of the author icon",
"description": "The URL of the provider",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
}
},
@@ -35723,6 +35697,26 @@
"required": ["text"],
"additionalProperties": false
},
"EmbedAuthorResponse": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The name of the author"},
"url": {
"description": "The URL of the author",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
},
"icon_url": {
"description": "The URL of the author icon",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
},
"proxy_icon_url": {
"description": "The proxied URL of the author icon",
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}]
}
},
"required": ["name"],
"additionalProperties": false
},
"EmbedMediaFlags": {
"type": "integer",
"minimum": 0,
@@ -38281,7 +38275,7 @@
"user_tag": {
"description": "Fluxer tag of the user being reported",
"type": "string",
"minLength": 3,
"minLength": 1,
"maxLength": 37
}
},
@@ -38333,7 +38327,7 @@
"reported_user_tag": {
"description": "Fluxer tag of the user who sent the message",
"type": "string",
"minLength": 3,
"minLength": 1,
"maxLength": 37
}
},
+21 -11
View File
@@ -41,6 +41,7 @@ import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDns
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {usesUniqueUsernames} from '@app/api/instance/AccountIdentityModeCache';
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
import {Logger} from '@app/api/Logger';
import type {Attachment} from '@app/api/models/Attachment';
@@ -61,6 +62,7 @@ import {ReportStatus, ReportType} from '@app/api/report/IReportRepository';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {isUnderEnforcement} from '@app/api/user/ProfileVisibility';
import {findPersonByLoginHandle, type ParsedLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {isAccountClosed} from '@app/api/user/UserHelpers';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import {buildHashedAssetKey} from '@app/api/worker/utils/AssetArchiveHelpers';
@@ -1149,7 +1151,7 @@ export class ReportService {
if (!parsed) {
throw new InvalidDsaReportTargetError();
}
const user = await this.userRepository.findByUsernameDiscriminator(parsed.username, parsed.discriminator);
const user = await this.lookupTaggedUser(parsed);
if (!user) {
throw new UnknownUserError();
}
@@ -1161,10 +1163,17 @@ export class ReportService {
if (!parsed || !userId) {
return false;
}
const user = await this.userRepository.findByUsernameDiscriminator(parsed.username, parsed.discriminator);
const user = await this.lookupTaggedUser(parsed);
return user?.id === userId;
}
private async lookupTaggedUser(parsed: ParsedLoginHandle): Promise<User | null> {
if (parsed.discriminator === null) {
return findPersonByLoginHandle(this.userRepository, parsed);
}
return this.userRepository.findByUsernameDiscriminator(parsed.username, parsed.discriminator);
}
private async isChannelReadableByEveryone(channel: Channel): Promise<boolean> {
if (!channel.guildId) {
return false;
@@ -1211,17 +1220,18 @@ export class ReportService {
return email.trim().toLowerCase();
}
private parseFluxerTag(tag: string): {
username: string;
discriminator: number;
} | null {
private parseFluxerTag(tag: string): ParsedLoginHandle | null {
const trimmed = tag.trim();
const match = /^(.+)#(\d{4})$/.exec(trimmed);
if (!match) return null;
return {
username: match[1],
discriminator: Number.parseInt(match[2], 10),
};
if (match) {
return {
username: match[1],
discriminator: Number.parseInt(match[2], 10),
};
}
if (!usesUniqueUsernames()) return null;
const handle = parseLoginHandle(trimmed);
return handle?.discriminator === null ? handle : null;
}
private extractChannelAndMessageFromLink(link: string): {
@@ -18,7 +18,7 @@ import {
type ReportFlowScreenDef,
} from '@app/api/report/flows/ReportFlowScreens';
import {getLegacyCategory, isReportReasonKey, type ReportReasonKey} from '@app/api/report/flows/ReportReasonCatalog';
import {type LocaleCode, Locales} from '@fluxer/constants/src/Locales';
import type {LocaleCode} from '@fluxer/constants/src/Locales';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InvalidReportFlowAnswersError} from '@fluxer/errors/src/domains/moderation/InvalidReportFlowAnswersError';
@@ -113,7 +113,6 @@ const REPORT_FLOW_CHECKED_INSTANCES: ReadonlyArray<ReportFlowInstance> = [
{selfHosted: true, guidelinesLinked: true},
];
const ADMIN_LOCALE = 'en-US';
const NORWEGIAN_LANGUAGE_CODES: ReadonlySet<string> = new Set(['nb', 'nn']);
export function getReportFlowScreenKind(screen: ReportFlowScreenDef): ReportFlowScreenKind {
if (screen.checklist) {
@@ -578,12 +577,7 @@ export function getReportFlowVariant(target: ReportFlowTargetType, surface: Repo
}
export function resolveReportFlowLocale(raw: string | null | undefined): LocaleCode {
if (!raw) {
return parseAcceptLanguage(null);
}
const tag = raw.trim().replaceAll('_', '-');
const language = tag.split(/[-,;]/)[0].toLowerCase();
return parseAcceptLanguage(NORWEGIAN_LANGUAGE_CODES.has(language) ? Locales.NO : tag);
return parseAcceptLanguage(raw);
}
function renderCopy(key: ReportFlowCopyKey, locale: string): string {
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
clearTestEmails,
createTestAccount,
createUniqueEmail,
findLastTestEmail,
listTestEmails,
} from '@app/api/auth/tests/AuthTestUtils';
import {createReportID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {ReportRepository} from '@app/api/report/ReportRepository';
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 {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ReportResponse {
report_id: string;
}
interface Me {
username: string;
discriminator: string;
}
async function issueTicket(harness: ApiTestHarness): Promise<string> {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-tag');
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email})
.expect(HTTP_STATUS.OK)
.execute();
const code = findLastTestEmail(await listTestEmails(harness), 'dsa_report_verification')?.metadata.code;
const {ticket} = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
return ticket;
}
function submitUserReport(harness: ApiTestHarness, ticket: string, userTag: string) {
return createBuilderWithoutAuth<ReportResponse>(harness).post('/reports/dsa').body({
ticket,
report_type: 'user',
category: 'harassment',
user_tag: userTag,
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
});
}
async function reportedUserIdOf(reportId: string): Promise<string | undefined> {
const report = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
return report?.reportedUserId?.toString();
}
async function readMe(harness: ApiTestHarness, token: string): Promise<Me> {
return createBuilder<Me>(harness, token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
}
describe('DSA user lookup by tag', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('an instance with random tags finds the user by username#1234 and rejects a bare username', async () => {
const target = await createTestAccount(harness);
const me = await readMe(harness, target.token);
const ticket = await issueTicket(harness);
await submitUserReport(harness, ticket, me.username)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_DSA_REPORT_TARGET)
.execute();
const result = await submitUserReport(harness, ticket, `${me.username}#${me.discriminator}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(await reportedUserIdOf(result.report_id)).toBe(target.userId);
});
test('an instance without tags finds the user by bare username and by username#0000', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup', TagStyles.NONE);
const target = await createTestAccount(harness);
const me = await readMe(harness, target.token);
expect(me.discriminator).toBe('0000');
for (const userTag of [me.username, ` ${me.username.toUpperCase()} `, `${me.username}#0000`]) {
const result = await submitUserReport(harness, await issueTicket(harness), userTag)
.expect(HTTP_STATUS.OK)
.execute();
expect(await reportedUserIdOf(result.report_id)).toBe(target.userId);
}
await submitUserReport(harness, await issueTicket(harness), 'nobody_has_this_name')
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_USER)
.execute();
} finally {
config.instance.selfHosted = originalSelfHosted;
getInstanceConfigRepository().clearCacheForTesting();
}
});
});
@@ -1192,7 +1192,15 @@ describe('report flow rendering', () => {
expect(resolveReportFlowLocale('nb')).toBe('no');
expect(resolveReportFlowLocale('nb_NO')).toBe('no');
expect(resolveReportFlowLocale('nn')).toBe('no');
expect(resolveReportFlowLocale('zh-Hant')).toBe('zh-CN');
expect(resolveReportFlowLocale('nn-NO')).toBe('no');
expect(resolveReportFlowLocale('zh-Hant')).toBe('zh-TW');
expect(resolveReportFlowLocale('zh_Hant_TW')).toBe('zh-TW');
expect(resolveReportFlowLocale('zh-HK')).toBe('zh-TW');
expect(resolveReportFlowLocale('zh-Hans')).toBe('zh-CN');
expect(resolveReportFlowLocale('zh')).toBe('zh-CN');
expect(resolveReportFlowLocale('fr-CA')).toBe('fr');
expect(resolveReportFlowLocale('de-DE')).toBe('de');
expect(resolveReportFlowLocale(' de ')).toBe('de');
expect(resolveReportFlowLocale('sv')).toBe('sv-SE');
expect(resolveReportFlowLocale('pt')).toBe('pt-BR');
expect(resolveReportFlowLocale('pt_BR')).toBe('pt-BR');
@@ -1200,6 +1208,7 @@ describe('report flow rendering', () => {
expect(resolveReportFlowLocale('xx-YY')).toBe('en-US');
expect(resolveReportFlowLocale(undefined)).toBe('en-US');
expect(getReportFlowResponse('message', 'in_app', 'nb').locale).toBe('no');
expect(getReportFlowResponse('message', 'in_app', 'zh-Hant').locale).toBe('zh-TW');
expect(getReportFlowResponse('message', 'in_app', 'xx-YY').locale).toBe('en-US');
});
});
@@ -423,7 +423,7 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
thumbnail: this.mapEmbedMedia(embed.thumbnail, message),
footer: this.mapEmbedFooter(embed.footer),
fields: embed.fields.length > 0 ? embed.fields.map((field) => this.mapEmbedField(field)) : null,
provider: this.mapEmbedAuthor(embed.provider),
provider: this.mapEmbedProvider(embed.provider),
video: this.mapEmbedMedia(embed.video, message),
audio: this.mapEmbedMedia(embed.audio, message),
html: embed.html,
@@ -434,14 +434,21 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
};
}
private mapEmbedAuthor(author: EmbedAuthor | EmbedProvider | null) {
private mapEmbedAuthor(author: EmbedAuthor | null) {
if (!author?.name) return null;
const iconUrl = 'iconUrl' in author ? author.iconUrl : null;
return {
name: author.name,
url: signOwnUrl(author.url),
icon_url: signOwnUrl(iconUrl),
proxy_icon_url: mediaProxyUrl(iconUrl),
icon_url: signOwnUrl(author.iconUrl),
proxy_icon_url: mediaProxyUrl(author.iconUrl),
};
}
private mapEmbedProvider(provider: EmbedProvider | null) {
if (!provider?.name) return null;
return {
name: provider.name,
url: signOwnUrl(provider.url),
};
}
@@ -15,6 +15,7 @@ import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageRes
import {
HarvestArchiveResponse,
HarvestCreationResponseSchema,
HarvestDownloadQuery,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
HarvestStatusResponseSchemaNullable,
@@ -282,6 +283,9 @@ export function UserContentController(app: HonoApp) {
'/harvest-downloads/:harvestId',
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD_FILE),
Validator('param', HarvestIdParam),
Validator('query', HarvestDownloadQuery, (result, ctx) =>
result.success ? undefined : ctx.text('Not Found', 404),
),
OpenAPI({
operationId: 'download_data_harvest_archive',
summary: 'Download data harvest archive',
@@ -295,10 +299,7 @@ export function UserContentController(app: HonoApp) {
}),
async (ctx) => {
const {harvestId} = ctx.req.valid('param');
const token = ctx.req.query('token');
if (!token) {
return ctx.text('Not Found', 404);
}
const {token} = ctx.req.valid('query');
try {
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
harvestId,
@@ -163,6 +163,30 @@ describe('Harvest download streaming', () => {
const {harvestId} = await completedHarvest(harness);
const response = await get(harness, `/harvest-downloads/${harvestId}`);
expect(response.status).toBe(404);
expect(await response.text()).toBe('Not Found');
});
});
test.each([
['an empty token', '?token='],
['a token longer than a download URL', `?token=${'a'.repeat(2049)}`],
['a token given twice', '?token=a&token=b'],
])('answers not found for %s', async (_label, query) => {
await withStreamingHarvestDownloads(async () => {
const {harvestId} = await completedHarvest(harness);
const response = await get(harness, `/harvest-downloads/${harvestId}${query}`);
expect(response.status).toBe(404);
expect(await response.text()).toBe('Not Found');
});
});
test('serves the archive when the link has other query parameters', async () => {
await withStreamingHarvestDownloads(async () => {
const {account, harvestId} = await completedHarvest(harness);
const download = await fetchHarvestDownload(harness, account.token, harvestId);
const response = await get(harness, `${downloadPath(download.download_url)}&source=email`);
expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(ZIP_BYTES);
});
});
});