mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(app,api): kick reasons, blocked counts and locale fallbacks (#3334)
This commit is contained in:
1 parent
a5c1362f4a
commit
4db13b0375
157 files changed
+3080
-1995
No files matched your search
@@ -4,6 +4,7 @@ import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
|
||||
import {ERROR_I18N_LOCALE_MESSAGES} from '@fluxer/errors/src/i18n/ErrorI18nLocales';
|
||||
import {ERROR_I18N_MESSAGES, type ErrorI18nCatalog, type ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nMessages';
|
||||
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 {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import type {
|
||||
@@ -34,7 +35,7 @@ const errorI18n = createStaticI18n<ErrorI18nKey, string, ErrorI18nRuntimeVariabl
|
||||
validateVariables: (_key, template, variables) => validateMessageTemplateVariables(template, variables),
|
||||
},
|
||||
(template, variables, mf) => {
|
||||
return String(mf.compile(template)(variables));
|
||||
return String(compileMessage(mf, template)(variables));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type MessageFormat from '@messageformat/core';
|
||||
|
||||
type CompiledMessage = (variables: Record<string, unknown>) => unknown;
|
||||
|
||||
const compiledMessagesByFormat = new WeakMap<MessageFormat, Map<string, CompiledMessage>>();
|
||||
|
||||
export function compileMessage(messageFormat: MessageFormat, template: string): CompiledMessage {
|
||||
let compiledMessages = compiledMessagesByFormat.get(messageFormat);
|
||||
if (!compiledMessages) {
|
||||
compiledMessages = new Map();
|
||||
compiledMessagesByFormat.set(messageFormat, compiledMessages);
|
||||
}
|
||||
let compiledMessage = compiledMessages.get(template);
|
||||
if (!compiledMessage) {
|
||||
compiledMessage = messageFormat.compile(template);
|
||||
compiledMessages.set(template, compiledMessage);
|
||||
}
|
||||
return compiledMessage;
|
||||
}
|
||||
@@ -103,12 +103,23 @@ function collectMessageTemplateVariables(tokens: ReadonlyArray<Token>, variables
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMessageTemplateVariables(template: string): Set<string> {
|
||||
const templateVariablesByTemplate = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
function getMessageTemplateVariables(template: string): ReadonlySet<string> {
|
||||
const cachedVariables = templateVariablesByTemplate.get(template);
|
||||
if (cachedVariables) {
|
||||
return cachedVariables;
|
||||
}
|
||||
const variables = new Set<string>();
|
||||
collectMessageTemplateVariables(parse(template), variables);
|
||||
templateVariablesByTemplate.set(template, variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function extractMessageTemplateVariables(template: string): Set<string> {
|
||||
return new Set(getMessageTemplateVariables(template));
|
||||
}
|
||||
|
||||
function collectMessageTemplatePlaceholders(tokens: ReadonlyArray<Token>, placeholders: Set<string>): void {
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
@@ -161,9 +172,9 @@ export function validateMessageTemplateVariables(
|
||||
template: string,
|
||||
variables: Record<string, unknown> | undefined,
|
||||
): string | null {
|
||||
let requiredVariables: Set<string>;
|
||||
let requiredVariables: ReadonlySet<string>;
|
||||
try {
|
||||
requiredVariables = extractMessageTemplateVariables(template);
|
||||
requiredVariables = getMessageTemplateVariables(template);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown parser error';
|
||||
return `Invalid i18n message template: ${message}`;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
|
||||
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
|
||||
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import {
|
||||
extractMessageTemplateVariables,
|
||||
validateMessageTemplateVariables,
|
||||
} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
|
||||
import MessageFormat from '@messageformat/core';
|
||||
import {parse} from '@messageformat/parser';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@messageformat/parser', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@messageformat/parser')>();
|
||||
return {...actual, parse: vi.fn(actual.parse)};
|
||||
});
|
||||
|
||||
const FILES_TEMPLATE = '{count, plural, one {# file one} other {# files other}}';
|
||||
|
||||
type TestKey = 'files' | 'greeting';
|
||||
|
||||
function unwrap(result: I18nResult<TestKey, string>): string {
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function createTestI18n() {
|
||||
return createStaticI18n<TestKey, string, Record<string, unknown>>(
|
||||
{
|
||||
defaultLocale: 'en-US',
|
||||
defaultMessages: {files: FILES_TEMPLATE, greeting: 'Hello {name}'},
|
||||
localeMessages: {fr: {files: FILES_TEMPLATE}},
|
||||
validateVariables: (_key, template, variables) => validateMessageTemplateVariables(template, variables),
|
||||
},
|
||||
(template, variables, mf) => String(compileMessage(mf, template)(variables)),
|
||||
);
|
||||
}
|
||||
|
||||
describe('compileMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('compiles a template once and reuses it across renders', () => {
|
||||
const messageFormat = new MessageFormat('en-US');
|
||||
const compileSpy = vi.spyOn(messageFormat, 'compile');
|
||||
const first = compileMessage(messageFormat, 'Hello {name}');
|
||||
const second = compileMessage(messageFormat, 'Hello {name}');
|
||||
expect(second).toBe(first);
|
||||
expect(first({name: 'Taylor'})).toBe('Hello Taylor');
|
||||
expect(second({name: 'Jordan'})).toBe('Hello Jordan');
|
||||
expect(compileSpy).toHaveBeenCalledTimes(1);
|
||||
compileMessage(messageFormat, 'Bye {name}');
|
||||
expect(compileSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('keeps compiled templates separate per locale so plural rules stay correct', () => {
|
||||
const english = new MessageFormat('en-US');
|
||||
const french = new MessageFormat('fr');
|
||||
const englishFiles = compileMessage(english, FILES_TEMPLATE);
|
||||
const frenchFiles = compileMessage(french, FILES_TEMPLATE);
|
||||
expect(frenchFiles).not.toBe(englishFiles);
|
||||
expect(englishFiles({count: 0})).toBe('0 files other');
|
||||
expect(frenchFiles({count: 0})).toBe('0 file one');
|
||||
expect(compileMessage(english, FILES_TEMPLATE)({count: 1})).toBe('1 file one');
|
||||
expect(compileMessage(french, FILES_TEMPLATE)({count: 2})).toBe('2 files other');
|
||||
});
|
||||
it('returns the raw value for a lone argument', () => {
|
||||
expect(compileMessage(new MessageFormat('en-US'), '{count}')({count: 3})).toBe(3);
|
||||
});
|
||||
it('compiles once per locale when rendering through createStaticI18n', () => {
|
||||
const compileSpy = vi.spyOn(MessageFormat.prototype, 'compile');
|
||||
const i18n = createTestI18n();
|
||||
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 0}))).toBe('0 files other');
|
||||
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 1}))).toBe('1 file one');
|
||||
expect(unwrap(i18n.getTemplate('files', 'fr', {count: 0}))).toBe('0 file one');
|
||||
expect(unwrap(i18n.getTemplate('files', 'fr', {count: 2}))).toBe('2 files other');
|
||||
expect(unwrap(i18n.getTemplate('greeting', 'en-US', {name: 'Taylor'}))).toBe('Hello Taylor');
|
||||
expect(unwrap(i18n.getTemplate('greeting', 'en-US', {name: 'Jordan'}))).toBe('Hello Jordan');
|
||||
expect(compileSpy).toHaveBeenCalledTimes(3);
|
||||
i18n.reset();
|
||||
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 3}))).toBe('3 files other');
|
||||
expect(compileSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message template variables', () => {
|
||||
it('parses a template once across validations', () => {
|
||||
const template = 'Hello {cachedName}, you have {cachedCount, plural, one {# invite} other {# invites}}';
|
||||
const parseMock = vi.mocked(parse);
|
||||
parseMock.mockClear();
|
||||
expect(validateMessageTemplateVariables(template, {cachedName: 'Taylor', cachedCount: 1})).toBeNull();
|
||||
expect(validateMessageTemplateVariables(template, {cachedName: 'Jordan', cachedCount: 2})).toBeNull();
|
||||
expect(validateMessageTemplateVariables(template, {cachedCount: 2})).toBe(
|
||||
'Missing required i18n variable: cachedName',
|
||||
);
|
||||
expect([...extractMessageTemplateVariables(template)].sort()).toEqual(['cachedCount', 'cachedName']);
|
||||
expect(parseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('hands out copies so callers cannot change the cached set', () => {
|
||||
const template = 'Welcome {copiedName}';
|
||||
const variables = extractMessageTemplateVariables(template);
|
||||
variables.add('injected');
|
||||
variables.delete('copiedName');
|
||||
expect([...extractMessageTemplateVariables(template)]).toEqual(['copiedName']);
|
||||
expect(validateMessageTemplateVariables(template, {copiedName: 'Taylor'})).toBeNull();
|
||||
expect(validateMessageTemplateVariables(template, {})).toBe('Missing required i18n variable: copiedName');
|
||||
});
|
||||
it('does not cache templates that fail to parse', () => {
|
||||
const parseMock = vi.mocked(parse);
|
||||
parseMock.mockClear();
|
||||
expect(validateMessageTemplateVariables('Broken {unclosed', {})).toMatch(/^Invalid i18n message template: /);
|
||||
expect(validateMessageTemplateVariables('Broken {unclosed', {})).toMatch(/^Invalid i18n message template: /);
|
||||
expect(parseMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -297,6 +297,72 @@ describe('published public and admin documents', () => {
|
||||
expect(status).toEqual({type: 'string', description: 'Current status of the report (pending, resolved)'});
|
||||
});
|
||||
|
||||
it('declares the token query parameter of the harvest archive download', () => {
|
||||
const operation = documentFor('public').paths['/harvest-downloads/{harvestId}'].get;
|
||||
expect(operation.parameters?.map(({name, in: location, required}) => ({name, in: location, required}))).toEqual([
|
||||
{name: 'harvestId', in: 'path', required: true},
|
||||
{name: 'token', in: 'query', required: true},
|
||||
]);
|
||||
expect(operation.parameters?.[1].schema).toMatchObject({type: 'string'});
|
||||
});
|
||||
|
||||
it('leaves the MFA challenge out of responses that never return one', () => {
|
||||
const document = documentFor('public');
|
||||
const responseSchema = (path: string) =>
|
||||
document.paths[path].post.responses['200'].content?.['application/json']?.schema;
|
||||
expect(document.components.schemas.AuthRegisterResponse).toEqual({
|
||||
anyOf: [
|
||||
{$ref: '#/components/schemas/AuthTokenWithUserIdResponse'},
|
||||
{$ref: '#/components/schemas/AuthRegistrationPendingApprovalResponse'},
|
||||
],
|
||||
});
|
||||
expect(responseSchema('/auth/register')).toEqual({$ref: '#/components/schemas/AuthRegisterResponse'});
|
||||
expect(responseSchema('/auth/email-revert')).toEqual({$ref: '#/components/schemas/AuthTokenWithUserIdResponse'});
|
||||
for (const path of ['/auth/login', '/auth/reset']) {
|
||||
expect(responseSchema(path)).toEqual({$ref: '#/components/schemas/AuthLoginResponse'});
|
||||
}
|
||||
const login = document.components.schemas.AuthLoginResponse.anyOf ?? [];
|
||||
expect(login).toHaveLength(2);
|
||||
expect(login[0]).toEqual({$ref: '#/components/schemas/AuthTokenWithUserIdResponse'});
|
||||
expect(login[1]).toHaveProperty('properties.mfa');
|
||||
});
|
||||
|
||||
it('describes an embed provider with a name and a link only', () => {
|
||||
const document = documentFor('public');
|
||||
expect(Object.keys(document.components.schemas.EmbedProviderResponse.properties ?? {})).toEqual(['name', 'url']);
|
||||
for (const name of ['MessageEmbedResponse', 'MessageEmbedChildResponse']) {
|
||||
const properties = document.components.schemas[name].properties;
|
||||
expect(properties?.provider).toMatchObject({
|
||||
anyOf: [{$ref: '#/components/schemas/EmbedProviderResponse'}, {type: 'null'}],
|
||||
});
|
||||
expect(properties?.author).toMatchObject({
|
||||
anyOf: [{$ref: '#/components/schemas/EmbedAuthorResponse'}, {type: 'null'}],
|
||||
});
|
||||
}
|
||||
expect(Object.keys(document.components.schemas.EmbedAuthorResponse.properties ?? {})).toEqual([
|
||||
'name',
|
||||
'url',
|
||||
'icon_url',
|
||||
'proxy_icon_url',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists only the fields the admin guild update returns', () => {
|
||||
const document = documentFor('admin');
|
||||
const guild = document.components.schemas.GuildUpdateResponse.properties?.guild;
|
||||
assert(typeof guild === 'object', 'the guild member of GuildUpdateResponse must be an object schema');
|
||||
expect(Object.keys(guild.properties ?? {})).toEqual([
|
||||
'id',
|
||||
'name',
|
||||
'features',
|
||||
'owner_id',
|
||||
'icon',
|
||||
'banner',
|
||||
'member_count',
|
||||
'nsfw_level',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the report status and type enums in the admin document', () => {
|
||||
const {schemas} = documentFor('admin').components;
|
||||
expect(schemas.ReportStatus).toMatchObject({type: 'integer', format: 'int32', enum: [0, 1]});
|
||||
|
||||
@@ -1269,9 +1269,6 @@ const AdminGuildResponseSchema = z.object({
|
||||
banner: createStringType(1, 256).nullable(),
|
||||
member_count: Int32Type,
|
||||
nsfw_level: NSFWLevelSchema.optional(),
|
||||
nsfw: z.boolean().optional(),
|
||||
content_warning_level: ContentWarningLevelSchema.optional(),
|
||||
content_warning_text: createStringType(0, CONTENT_WARNING_TEXT_MAX_LENGTH).nullable().optional(),
|
||||
});
|
||||
const AdminGuildChannelSummarySchema = z.object({
|
||||
id: SnowflakeStringType,
|
||||
|
||||
@@ -232,11 +232,7 @@ export const RecoverAccountResponse = z.union([
|
||||
|
||||
export type RecoverAccountResponse = z.infer<typeof RecoverAccountResponse>;
|
||||
|
||||
export const AuthRegisterResponse = z.union([
|
||||
AuthTokenWithUserIdResponse,
|
||||
AuthMfaRequiredResponse,
|
||||
AuthRegistrationPendingApprovalResponse,
|
||||
]);
|
||||
export const AuthRegisterResponse = z.union([AuthTokenWithUserIdResponse, AuthRegistrationPendingApprovalResponse]);
|
||||
|
||||
export type AuthRegisterResponse = z.infer<typeof AuthRegisterResponse>;
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ export const EmbedAuthorResponse = z.object({
|
||||
|
||||
export type EmbedAuthorResponse = z.infer<typeof EmbedAuthorResponse>;
|
||||
|
||||
export const EmbedProviderResponse = z.object({
|
||||
name: z.string().describe('The name of the provider'),
|
||||
url: z.url().nullish().describe('The URL of the provider'),
|
||||
});
|
||||
|
||||
export const EmbedFooterResponse = z.object({
|
||||
text: z.string().describe('The footer text'),
|
||||
icon_url: z.url().nullish().describe('The URL of the footer icon'),
|
||||
@@ -61,7 +66,7 @@ export const MessageEmbedChildResponse = z.object({
|
||||
thumbnail: EmbedMediaResponse.nullish().describe('The thumbnail of the embed'),
|
||||
footer: EmbedFooterResponse.nullish().describe('The footer of the embed'),
|
||||
fields: z.array(EmbedFieldResponse).nullish().describe('The fields of the embed'),
|
||||
provider: EmbedAuthorResponse.nullish().describe('The provider of the embed (e.g., YouTube, Twitter)'),
|
||||
provider: EmbedProviderResponse.nullish().describe('The provider of the embed (e.g., YouTube, Twitter)'),
|
||||
video: EmbedMediaResponse.nullish().describe('The video of the embed'),
|
||||
audio: EmbedMediaResponse.nullish().describe('The audio of the embed'),
|
||||
html: z.string().nullish().describe('Sanitized oEmbed HTML for trusted specialized embed renderers'),
|
||||
@@ -89,6 +94,11 @@ export interface EmbedAuthor {
|
||||
readonly proxy_icon_url?: string;
|
||||
}
|
||||
|
||||
export interface EmbedProvider {
|
||||
readonly name: string;
|
||||
readonly url?: string;
|
||||
}
|
||||
|
||||
export interface EmbedFooter {
|
||||
readonly text: string;
|
||||
readonly icon_url?: string;
|
||||
@@ -128,7 +138,7 @@ export interface MessageEmbedChild {
|
||||
readonly thumbnail?: EmbedMedia;
|
||||
readonly footer?: EmbedFooter;
|
||||
readonly fields?: ReadonlyArray<EmbedField>;
|
||||
readonly provider?: EmbedAuthor;
|
||||
readonly provider?: EmbedProvider;
|
||||
readonly video?: EmbedMedia;
|
||||
readonly audio?: EmbedMedia;
|
||||
readonly html?: string;
|
||||
|
||||
@@ -84,13 +84,13 @@ export const TicketResponse = z.object({
|
||||
|
||||
export type TicketResponse = z.infer<typeof TicketResponse>;
|
||||
|
||||
const FLUXER_TAG_REGEX = /^([^#]{1,32})#([0-9]{4})$/;
|
||||
const FLUXER_TAG_REGEX = /^([^#]{1,32})(#[0-9]{4})?$/;
|
||||
const FLUXER_TAG_TYPE = z
|
||||
.string()
|
||||
.min(3)
|
||||
.min(1)
|
||||
.max(37)
|
||||
.refine((value) => FLUXER_TAG_REGEX.test(value), 'Fluxer tag must be in the format username#1234')
|
||||
.describe('A Fluxer username tag in the format username#1234');
|
||||
.refine((value) => FLUXER_TAG_REGEX.test(value), 'Fluxer tag must be in the format username#1234 or username')
|
||||
.describe('A Fluxer username tag in the format username#1234, or a bare username on an instance without user tags');
|
||||
const EuCountryCodeEnum = withOpenApiType(
|
||||
createNamedStringLiteralUnion(
|
||||
[
|
||||
|
||||
@@ -59,4 +59,8 @@ export const HarvestDownloadUrlResponse = z.object({
|
||||
|
||||
export type HarvestDownloadUrlResponse = z.infer<typeof HarvestDownloadUrlResponse>;
|
||||
|
||||
export const HarvestDownloadQuery = z.object({
|
||||
token: createStringType(1, 2048).describe('The signed download token from the harvest download URL'),
|
||||
});
|
||||
|
||||
export const HarvestArchiveResponse = z.file().describe('The ZIP archive containing the requested user data');
|
||||
Reference in new issue
Block a user