refactor(schema): simplify validation and OpenAPI generation (#2709)

This commit is contained in:
Hampus
2026-09-11 23:24:26 +02:00
committed by GitHub
parent 4bc5593f9f
commit de1fd95a99
510 changed files with 29494 additions and 36131 deletions
-1
View File
@@ -22,7 +22,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
-1
View File
@@ -92,7 +92,6 @@
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
},
"packageManager": "[email protected]"
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -21,7 +21,6 @@
"@types/node": "catalog:",
"@types/nodemailer": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -2
View File
@@ -1,11 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
export default defineConfig({
root: process.cwd(),
plugins: [tsconfigPaths()],
resolve: {tsconfigPaths: true},
cacheDir: './node_modules/.vitest',
test: {
globals: true,
-1
View File
@@ -18,7 +18,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
@@ -215,7 +215,7 @@ function createBlockedRequestError(url: URL, context: RequestUrlValidationContex
}
async function defaultLookupHost(hostname: string): Promise<Array<string>> {
const addresses = await dns.promises.lookup(hostname, {all: true, verbatim: true});
const addresses = await dns.promises.lookup(hostname, {all: true, order: 'verbatim'});
return addresses.map((addressEntry) => addressEntry.address);
}
@@ -234,7 +234,7 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
if (error) {
callback(error, []);
return;
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
@@ -38,7 +38,6 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
"vitest": "catalog:"
}
}
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
+15 -71
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
InputValidationError,
type LocalizedValidationError,
@@ -9,7 +9,7 @@ import {
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import type {ZodError, ZodTypeAny} from 'zod';
import type {core, input, output, ZodSafeParseResult, ZodType} from 'zod';
import {requireRequestJsonBody} from './utils/RequestJsonBody';
import {initializeFluxerErrorMap} from './ZodErrorMap';
@@ -19,12 +19,6 @@ function isEmptyObject(obj: object): boolean {
return Object.keys(obj).length === 0;
}
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getValidationErrorCode(message: string): ValidationErrorCode {
if (isValidationErrorCode(message)) {
return message;
@@ -32,60 +26,19 @@ function getValidationErrorCode(message: string): ValidationErrorCode {
return ValidationErrorCodes.INVALID_FORMAT;
}
interface ZodTooSmallIssue {
code: 'too_small';
minimum: number | bigint;
type: string;
}
interface ZodTooBigIssue {
code: 'too_big';
maximum: number | bigint;
type: string;
}
function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue {
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
}
function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue {
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
}
interface ZodInvalidTypeIssue {
code: 'invalid_type';
expected: string;
received: string;
}
interface ZodCustomIssue {
code: 'custom';
params?: Record<string, unknown>;
}
function isInvalidTypeIssue(
issue: ZodError['issues'][number],
): issue is ZodError['issues'][number] & ZodInvalidTypeIssue {
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
}
function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue {
return issue.code === 'custom';
}
function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record<string, unknown> | undefined {
function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknown> {
const path = issue.path;
const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field';
if (isTooSmallIssue(issue)) {
if (issue.code === 'too_small') {
return {name: fieldName, min: issue.minimum, minValue: issue.minimum};
}
if (isTooBigIssue(issue)) {
if (issue.code === 'too_big') {
return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum};
}
if (isInvalidTypeIssue(issue)) {
return {name: fieldName, expected: issue.expected, received: issue.received};
if (issue.code === 'invalid_type') {
return {name: fieldName, expected: issue.expected};
}
if (isCustomIssue(issue) && issue.params) {
if (issue.code === 'custom' && issue.params) {
return {name: fieldName, ...issue.params};
}
return {name: fieldName};
@@ -106,24 +59,15 @@ function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
}
type HasUndefined<T> = undefined extends T ? true : false;
type SafeParseResult<T extends ZodTypeAny> =
| {
success: true;
data: T['_output'];
}
| {
success: false;
error: ZodError<T['_input']>;
};
type Hook<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
V extends Input = Input,
O = Record<string, unknown>,
> = (
result: SafeParseResult<T> & {
result: ZodSafeParseResult<output<T>> & {
target: Target;
},
c: Context<E, P, V>,
@@ -134,7 +78,7 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
target: Target,
) => unknown | Promise<unknown>;
type ValidatorOptions<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
@@ -144,7 +88,7 @@ type ValidatorOptions<
post?: Hook<T, E, P, Target, V>;
};
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
export function inputValidationErrorFromZodIssues(issues: Array<core.$ZodIssue>): InputValidationError {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
const seen = new Set<string>();
@@ -162,12 +106,12 @@ export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): I
}
export const Validator = <
T extends ZodTypeAny,
T extends ZodType,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = T['_input'],
Out = T['_output'],
In = input<T>,
Out = output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
+7 -37
View File
@@ -1,37 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {z} from 'zod';
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
type FluxerZodErrorMapIssue = z.core.$ZodRawIssue;
type FluxerZodErrorMapResult =
| {
message: string;
}
| string
| undefined
| null;
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getParamsProperty(obj: object): Record<string, unknown> | undefined {
if ('params' in obj) {
const value = (
obj as {
params?: unknown;
}
).params;
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}
return undefined;
}
function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResult {
const fluxerZodErrorMap: z.core.$ZodErrorMap = (issue) => {
if (issue.message && isValidationErrorCode(issue.message)) {
return {message: issue.message};
}
@@ -58,8 +31,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'too_big': {
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'string') {
if (issue.origin === 'string') {
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -67,10 +39,9 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'invalid_format': {
const format = 'format' in issue ? String(issue.format) : undefined;
if (format === 'email') {
if (issue.format === 'email') {
errorCode = ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
} else if (format === 'uuid') {
} else if (issue.format === 'uuid') {
errorCode = ValidationErrorCodes.INVALID_SNOWFLAKE;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -82,8 +53,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'custom': {
const params = getParamsProperty(issue);
const customErrorCode = params?.['error_code'];
const customErrorCode = issue.params?.['error_code'];
errorCode =
typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)
? customErrorCode
@@ -101,7 +71,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
}
}
return {message: errorCode};
}
};
export function initializeFluxerErrorMap(): void {
z.config({customError: fluxerZodErrorMap});
@@ -2,6 +2,7 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
AdminApiKeyListResponse,
CreateAdminApiKeyRequest,
CreateAdminApiKeyResponse,
type CreateAdminApiKeyResponse as CreateAdminApiKeyResponseType,
@@ -11,7 +12,7 @@ import {
UpdateAdminApiKeyRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {z} from 'zod';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -72,7 +73,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
OpenAPI({
operationId: 'list_admin_api_keys',
summary: 'List admin API keys',
responseSchema: z.array(ListAdminApiKeyResponse),
responseSchema: AdminApiKeyListResponse,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
@@ -3,8 +3,11 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {
AdminArchiveCreateRequest,
AdminArchiveResponseSchema,
type ArchiveSubjectType,
} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {
AdminArchiveCreateRequest,
DownloadUrlResponseSchema,
GetArchiveResponseSchema,
ListArchivesQuery,
@@ -19,7 +22,7 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean {
function canViewArchive(adminAcls: Set<string>, subjectType: ArchiveSubjectType): boolean {
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
@@ -39,7 +39,7 @@ import {
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodTypeAny, z} from 'zod';
import type {ZodType} from 'zod';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -221,7 +221,7 @@ function requireProfileSubstringScope(scope: ProfileSubstringScope | undefined):
return scope;
}
async function parseBlocklistBody<T extends ZodTypeAny>(schema: T, value: unknown): Promise<z.infer<T>> {
async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promise<T> {
const result = await schema.safeParseAsync(value);
if (!result.success) {
throw inputValidationErrorFromZodIssues(result.error.issues);
@@ -6,17 +6,17 @@ import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas
import {
DiscoveryAdminApplicationUpdateRequest,
DiscoveryAdminCategoryListingQuery,
DiscoveryAdminListedGuildResponse,
DiscoveryAdminListedGuildListResponse,
DiscoveryAdminListingBulkCategoryRequest,
DiscoveryAdminListingBulkCategoryResponse,
DiscoveryAdminPendingApplicationResponse,
DiscoveryAdminPendingApplicationListResponse,
DiscoveryAdminRemoveRequest,
DiscoveryApplicationPatchRequest,
DiscoveryApplicationResponse,
DiscoveryCategoryIdParam,
DiscoveryCategoryListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {z} from 'zod';
import {createGuildID} from '../../BrandedTypes';
import type {GuildDiscoveryRow} from '../../database/types/GuildDiscoveryTypes';
import {mapGuildFeatures} from '../../guild/GuildFeatureUtils';
@@ -138,7 +138,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List discovery applications',
description:
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
responseSchema: DiscoveryAdminPendingApplicationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -214,7 +214,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List guilds in a discovery category',
description:
'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -249,7 +249,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List discovery listings',
description:
'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -17,9 +17,8 @@ import {
ListGuildStickersResponse,
LookupGuildResponse,
SearchGuildsResponse,
SuccessResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildIdParam, GuildIdUserIdParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import {createGuildID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
@@ -17,13 +17,13 @@ import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/Gatew
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {
type InstanceBrandingConfig,
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
@@ -229,13 +229,16 @@ export function InstanceConfigAdminController(app: HonoApp) {
await getGatewayRolloutConfigPublisher().publish(validated);
}
if (data.voice_noise_suppression) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...data.voice_noise_suppression,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
@@ -435,7 +438,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
base64Image: image ?? null,
errorPath: 'image',
});
const brandingPatch: Partial<InstanceBrandingConfig> = {[`${kind}_url`]: prepared.newCdnUrl};
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
return ctx.json(await buildInstanceConfigResponse());
},
@@ -23,6 +23,7 @@ export function SystemAdminController(app: HonoApp) {
description:
'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
responseSchema: HeapSnapshotResponse,
responseContentType: 'application/octet-stream',
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -5,6 +5,7 @@ import {
CreateVoiceRegionRequest,
CreateVoiceRegionResponse,
CreateVoiceServerRequest,
CreateVoiceServerRequestBody,
CreateVoiceServerResponse,
DeleteVoiceResponse,
GetVoiceRegionQuery,
@@ -14,8 +15,10 @@ import {
ListVoiceRegionsResponse,
ListVoiceServersResponse,
UpdateVoiceRegionRequest,
UpdateVoiceRegionRequestBody,
UpdateVoiceRegionResponse,
UpdateVoiceServerRequest,
UpdateVoiceServerRequestBody,
UpdateVoiceServerResponse,
VoiceRegionIdParam,
VoiceServerIdParam,
@@ -117,6 +120,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_region',
summary: 'Update voice region',
requestSchema: UpdateVoiceRegionRequestBody,
responseSchema: UpdateVoiceRegionResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -195,6 +199,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'create_admin_voice_server',
summary: 'Create voice server',
requestSchema: CreateVoiceServerRequestBody,
responseSchema: CreateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -247,6 +252,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_server',
summary: 'Update voice server',
requestSchema: UpdateVoiceServerRequestBody,
responseSchema: UpdateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -1,11 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
export type ArchiveSubjectType = 'user' | 'guild';
export class AdminArchive {
subjectType: ArchiveSubjectType;
subjectId: bigint;
@@ -61,22 +58,7 @@ export class AdminArchive {
};
}
toResponse(): {
archive_id: string;
subject_type: ArchiveSubjectType;
subject_id: string;
requested_by: string;
requested_at: string;
started_at: string | null;
completed_at: string | null;
failed_at: string | null;
file_size: string | null;
progress_percent: number;
progress_step: string | null;
error_message: string | null;
download_url_expires_at: string | null;
expires_at: string | null;
} {
toResponse(): AdminArchiveResponse {
return {
archive_id: this.archiveId.toString(),
subject_type: this.subjectType,
@@ -95,5 +77,3 @@ export class AdminArchive {
};
}
}
export type AdminArchiveResponse = z.infer<typeof AdminArchiveResponseSchema>;
@@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {ms} from 'itty-time';
import {BatchBuilder, fetchMany, fetchOne} from '../../database/CassandraQueryExecution';
import {Db} from '../../database/CassandraTypes';
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
import {Logger} from '../../Logger';
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '../../Tables';
import type {ArchiveSubjectType} from '../models/AdminArchiveModel';
import {AdminArchive} from '../models/AdminArchiveModel';
const RETENTION_DAYS = 365;
@@ -7,6 +7,7 @@ import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestF
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms, seconds} from 'itty-time';
import type {GuildID, UserID} from '../../BrandedTypes';
@@ -16,7 +17,7 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {IStorageService} from '../../infrastructure/IStorageService';
import type {IUserRepository} from '../../user/IUserRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import {AdminArchive, type AdminArchiveResponse, type ArchiveSubjectType} from '../models/AdminArchiveModel';
import {AdminArchive} from '../models/AdminArchiveModel';
import type {AdminArchiveRepository} from '../repositories/AdminArchiveRepository';
const ARCHIVE_RETENTION_DAYS = 365;
@@ -130,19 +131,14 @@ export class AdminArchiveService {
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
if (subjectId !== undefined && subjectType === 'all') {
throw InputValidationError.create(
'subject_type',
'subject_type must name user or guild when subject_id is supplied',
);
}
if (subjectId !== undefined) {
const archives = await this.adminArchiveRepository.listBySubject(
subjectType as ArchiveSubjectType,
subjectId,
limit,
includeExpired,
);
if (subjectType === 'all') {
throw InputValidationError.create(
'subject_type',
'subject_type must name user or guild when subject_id is supplied',
);
}
const archives = await this.adminArchiveRepository.listBySubject(subjectType, subjectId, limit, includeExpired);
return archives.map((a) => a.toResponse());
}
if (requestedBy !== undefined) {
@@ -159,11 +155,7 @@ export class AdminArchiveService {
.slice(0, limit)
.map((a) => a.toResponse());
}
const archives = await this.adminArchiveRepository.listByType(
subjectType as ArchiveSubjectType,
limit,
includeExpired,
);
const archives = await this.adminArchiveRepository.listByType(subjectType, limit, includeExpired);
return archives.map((a) => a.toResponse());
}
@@ -1,5 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
AdminAuditLogChannelSummary,
AdminAuditLogGuildSummary,
AdminAuditLogResponse,
AdminAuditLogUserSummary,
AuditLogsListResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserID} from '../../BrandedTypes';
import {createChannelID, createGuildID, createUserID} from '../../BrandedTypes';
import type {IChannelRepository} from '../../channel/IChannelRepository';
@@ -70,10 +77,7 @@ export class AdminAuditService {
target_id?: string;
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const auditLogSearchService = getAuditLogSearchService();
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
@@ -116,10 +120,7 @@ export class AdminAuditService {
sort_order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const auditLogSearchService = getAuditLogSearchService();
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
@@ -164,10 +165,7 @@ export class AdminAuditService {
targetId?: bigint;
limit?: number;
offset?: number;
}): Promise<{
logs: Array<AdminAuditLogResponse>;
total: number;
}> {
}): Promise<AuditLogsListResponse> {
const limit = data.limit || 50;
const allLogs = await this.adminRepository.listAllAuditLogsPaginated(limit + (data.offset || 0));
let filteredLogs = allLogs;
@@ -320,24 +318,6 @@ export class AdminAuditService {
}
}
interface AdminAuditLogResponse {
log_id: string;
admin_user_id: string;
admin_user: AuditLogUserSummary | null;
target_type: string;
target_id: string;
target_user: AuditLogUserSummary | null;
target_guild: AuditLogGuildSummary | null;
target_channel: AuditLogChannelSummary | null;
related_users: Record<string, AuditLogUserSummary>;
related_guilds: Record<string, AuditLogGuildSummary>;
related_channels: Record<string, AuditLogChannelSummary>;
action: string;
audit_log_reason: string | null;
metadata: Record<string, string>;
created_at: string;
}
interface AuditLogEnrichmentDeps {
userRepository?: Pick<IUserRepository, 'findUnique'>;
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
@@ -350,25 +330,6 @@ interface AuditLogEnrichment {
channels: Map<string, Channel>;
}
interface AuditLogUserSummary {
id: string;
username: string;
discriminator: string;
global_name: string | null;
}
interface AuditLogGuildSummary {
id: string;
name: string;
}
interface AuditLogChannelSummary {
id: string;
name: string | null;
type: number;
guild_id: string | null;
}
const USER_TARGET_TYPES = new Set(['user', 'guild_member', 'message_deletion', 'message_shred']);
const SNOWFLAKE_RE = /^(0|[1-9][0-9]*)$/;
@@ -388,7 +349,7 @@ function isChannelIdKey(key: string): boolean {
return key === 'channel_id' || key.endsWith('_channel_id');
}
function mapUserSummary(user: User | null): AuditLogUserSummary | null {
function mapUserSummary(user: User | null): AdminAuditLogUserSummary | null {
if (!user) return null;
return {
id: user.id.toString(),
@@ -398,7 +359,7 @@ function mapUserSummary(user: User | null): AuditLogUserSummary | null {
};
}
function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
function mapGuildSummary(guild: Guild | null): AdminAuditLogGuildSummary | null {
if (!guild) return null;
return {
id: guild.id.toString(),
@@ -406,7 +367,7 @@ function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
};
}
function mapChannelSummary(channel: Channel | null): AuditLogChannelSummary | null {
function mapChannelSummary(channel: Channel | null): AdminAuditLogChannelSummary | null {
if (!channel) return null;
return {
id: channel.id.toString(),
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createGuildID, type GuildID, type UserID} from '../../../BrandedTypes';
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '../../../guild/services/GuildService';
@@ -17,7 +18,7 @@ interface AdminGuildManagementServiceDeps {
export class AdminGuildManagementService {
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
const {guildRepository, gatewayService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
const guild = await guildRepository.findUnique(guildId);
@@ -36,7 +37,11 @@ export class AdminGuildManagementService {
return {success: true};
}
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async shutdownGuild(
guildIdRaw: bigint,
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SuccessResponse> {
const {guildRepository, gatewayService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
const guild = await guildRepository.findUnique(guildId);
@@ -55,7 +60,7 @@ export class AdminGuildManagementService {
return {success: true};
}
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
const {guildService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
await guildService.data.deleteGuildForAdmin(guildId, auditLogReason);
@@ -8,6 +8,7 @@ import type {
ForceAddUserToGuildRequest,
KickGuildMemberRequest,
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createGuildID, createUserID, type UserID} from '../../../BrandedTypes';
import type {GuildService} from '../../../guild/services/GuildService';
import {createRequestCache, type RequestCache} from '../../../middleware/RequestCacheMiddleware';
@@ -34,7 +35,7 @@ export class AdminGuildMembershipService {
requestCache: RequestCache;
adminUserId: UserID;
auditLogReason: string | null;
}) {
}): Promise<SuccessResponse> {
const {userRepository, guildService, auditService} = this.deps;
const userId = createUserID(data.user_id);
const guildId = createGuildID(data.guild_id);
+2 -3
View File
@@ -19,7 +19,7 @@ import {
IpAuthorizationPollQuery,
IpAuthorizationPollResponse,
LoginRequest,
LogoutAuthSessionsRequest,
LogoutAuthSessionsWithVerificationRequest,
MfaTicketRequest,
MfaTotpRequest,
RegisterRequest,
@@ -30,7 +30,6 @@ import {
SsoStartRequest,
SsoStartResponse,
SsoStatusResponse,
SudoVerificationSchema,
UsernameSuggestionsRequest,
UsernameSuggestionsResponse,
ValidateResetPasswordTokenResponse,
@@ -353,7 +352,7 @@ export function AuthController(app: HonoApp) {
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema)),
Validator('json', LogoutAuthSessionsWithVerificationRequest),
OpenAPI({
operationId: 'logout_all_sessions',
summary: 'Logout all sessions',
@@ -256,8 +256,8 @@ function buildRegistrationAuthData(device: WebAuthnDevice): Buffer {
let x: Buffer, y: Buffer;
const asn1Offset = pubKeyBuf.indexOf(Buffer.from([0x30, 0x59, 0x30, 0x13]));
if (asn1Offset > 0 && pubKeyBuf.length >= asn1Offset + 68) {
x = pubKeyBuf.slice(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
y = pubKeyBuf.slice(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
x = pubKeyBuf.subarray(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
y = pubKeyBuf.subarray(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
} else {
x = randomBytes(32);
y = randomBytes(32);
@@ -4,13 +4,14 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ChannelUpdateRequest,
ChannelUpdateRequestBody,
DeleteChannelQuery,
PermissionOverwriteCreateRequest,
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import {
ChannelResponse,
ChannelSlowmodeStateResponse,
RtcRegionResponse,
RtcRegionListResponse,
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {
ChannelIdOverwriteIdParam,
@@ -18,7 +19,7 @@ import {
ChannelIdUserIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {createChannelID, createUserID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
@@ -98,7 +99,7 @@ export function ChannelController(app: HonoApp) {
summary: 'List RTC regions',
description:
'Returns available voice and video calling regions for the channel, used to optimise connection quality. Requires membership with call permissions.',
responseSchema: z.array(RtcRegionResponse),
responseSchema: RtcRegionListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
@@ -141,6 +142,7 @@ export function ChannelController(app: HonoApp) {
}),
OpenAPI({
operationId: 'update_channel',
requestSchema: ChannelUpdateRequestBody,
summary: 'Update channel settings',
description:
'Modifies channel properties such as name, description, topic, nsfw flag, and slowmode. Requires management permissions in the channel.',
@@ -25,9 +25,11 @@ import {
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {
BulkMessageFetchResponse,
MessageListResponse,
MessagePurgeResponse,
MessageResponseSchema,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {z} from 'zod';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {createAttachmentID, createChannelID, createMessageID} from '../../BrandedTypes';
import {Config} from '../../Config';
@@ -53,7 +55,7 @@ export function MessageController(app: HonoApp) {
OpenAPI({
operationId: 'list_messages',
summary: 'List messages in a channel',
responseSchema: z.array(MessageResponseSchema),
responseSchema: MessageListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Channels', 'Messages'],
@@ -431,7 +433,7 @@ export function MessageController(app: HonoApp) {
OpenAPI({
operationId: 'purge_personal_notes_messages',
summary: 'Purge all messages in personal notes',
responseSchema: z.object({deleted_count: z.number().int().nonnegative()}),
responseSchema: MessagePurgeResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Channels', 'Messages'],
@@ -2,6 +2,7 @@
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {
StreamPreviewResponse,
StreamPreviewUploadBodySchema,
StreamPreviewUploadUrlBodySchema,
StreamPreviewUploadUrlResponseSchema,
@@ -54,7 +55,8 @@ export function StreamController(app: HonoApp) {
summary: 'Get stream preview image',
description:
'Retrieves the current preview thumbnail for a stream. Returns the image with no-store cache headers to ensure freshness.',
responseSchema: null,
responseSchema: StreamPreviewResponse,
responseContentType: 'image/*',
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
@@ -40,6 +40,7 @@ import type {Attachment} from '../../models/Attachment';
import type {Channel} from '../../models/Channel';
import type {Message} from '../../models/Message';
import type {IUserRepository} from '../../user/IUserRepository';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
import type {UploadedAttachment} from '../AttachmentDTOs';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
@@ -488,24 +489,6 @@ export class AttachmentUploadService {
}
}
async function mapWithConcurrency<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<Array<TResult>> {
const results = new Array<TResult>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
for (;;) {
const index = nextIndex++;
if (index >= items.length) return;
results[index] = await mapper(items[index]!, index);
}
}
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
return results;
}
async function runAttachmentStorageOperation<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
@@ -23,6 +23,7 @@ import {hashFileSha256} from '../../../infrastructure/StorageObjectHelpers';
import {Logger} from '../../../Logger';
import type {Channel} from '../../../models/Channel';
import type {Message} from '../../../models/Message';
import {mapWithConcurrency} from '../../../utils/ConcurrencyUtils';
import type {AttachmentToProcess} from '../../AttachmentDTOs';
import type {AttachmentUploadTraceRepository} from '../../repositories/message/AttachmentUploadTraceRepository';
import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds} from './MessageHelpers';
@@ -30,26 +31,6 @@ import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds
const ATTACHMENT_PROCESSING_CONCURRENCY = 2;
const METADATA_PROBE_DEGRADED_CONTEXT = 'message_attachment';
async function mapWithConcurrency<T, R>(
items: ReadonlyArray<T>,
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<Array<R>> {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (true) {
const index = nextIndex++;
if (index >= items.length) return;
results[index] = await fn(items[index], index);
}
};
const workerCount = Math.min(limit, items.length);
await Promise.all(Array.from({length: workerCount}, () => worker()));
return results;
}
interface ProcessAttachmentParams {
message: Message;
attachment: AttachmentToProcess;
@@ -41,7 +41,7 @@ export async function parseMultipartMessageData(
ctx: Context<HonoEnv>,
user: User,
channelId: ChannelID,
schema: z.ZodTypeAny,
schema: z.ZodType<MessageRequest | MessageUpdateRequest>,
options?: ParseMultipartMessageDataOptions,
): Promise<MessageRequest | MessageUpdateRequest> {
let body: MultipartBody;
@@ -9,6 +9,7 @@ import type {
import type {ChannelID, MessageID, UserID} from '../../../BrandedTypes';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import type {User} from '../../../models/User';
import {mapWithConcurrency} from '../../../utils/ConcurrencyUtils';
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
import type {ChannelService} from '../ChannelService';
import {isPersonalNotesChannel} from './MessageHelpers';
@@ -153,21 +154,3 @@ export class MessageRequestService {
});
}
}
async function mapWithConcurrency<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<Array<TResult>> {
const results = new Array<TResult>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
for (;;) {
const index = nextIndex++;
if (index >= items.length) return;
results[index] = await mapper(items[index], index);
}
}
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
return results;
}
@@ -1,19 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {
BulkDeleteSelfMessagesGuildFilterMode,
BulkDeleteSelfMessagesScope,
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserID} from '../../../BrandedTypes';
import type {Channel} from '../../../models/Channel';
export type SelfMessageScopeMode = 'selected' | 'inaccessible_only';
export type SelfMessageGuildFilterMode = 'exclude' | 'include_only';
export interface SelfMessageFilter {
scope: SelfMessageScopeMode;
scope: BulkDeleteSelfMessagesScope;
includeDms: boolean;
includeDmsClosed: boolean;
includeGroupDms: boolean;
includeGuilds: boolean;
guildFilterMode: SelfMessageGuildFilterMode;
guildFilterMode: BulkDeleteSelfMessagesGuildFilterMode;
excludedGuildIds: ReadonlySet<string>;
includedGuildIds: ReadonlySet<string>;
startTimestamp: number | null;
@@ -10,6 +10,7 @@ import type {IStorageService} from '../../../infrastructure/IStorageService';
import {Logger} from '../../../Logger';
import type {Message} from '../../../models/Message';
import {deleteMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {chunkArray} from '../../../utils/ArrayUtils';
import {ChannelEventDispatcher} from '../../../worker/services/ChannelEventDispatcher';
import {purgeMessageAttachments} from './MessageHelpers';
import {
@@ -50,14 +51,6 @@ interface MessageWithChannel {
message: Message;
}
function chunkArray<T>(items: Array<T>, chunkSize: number): Array<Array<T>> {
const chunks: Array<Array<T>> = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
export class UserMessageDeletionService {
private readonly eventDispatcher: ChannelEventDispatcher;
private readonly FETCH_BATCH_SIZE = 100;
@@ -9,6 +9,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NcmecAlreadySubmittedError} from '@fluxer/errors/src/domains/moderation/NcmecAlreadySubmittedError';
import {NcmecSubmissionFailedError} from '@fluxer/errors/src/domains/moderation/NcmecSubmissionFailedError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import type {NcmecSubmissionStatus} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
@@ -58,8 +59,6 @@ import type {NcmecApiClient} from './NcmecReporter';
import {buildNcmecFileDetailsXml, buildNcmecReportXml} from './NcmecReporter';
import type {NcmecRepository} from './NcmecRepository';
export type NcmecSubmissionStatus = 'not_submitted' | 'submitting' | 'submitted' | 'failed';
export interface NcmecAttachmentStatusResponse {
status: NcmecSubmissionStatus;
ncmec_report_id: string | null;
@@ -4,16 +4,11 @@ import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUn
import {getClient} from '@pkgs/cassandra/src/Client';
import cassandra from 'cassandra-driver';
import {Logger} from '../Logger';
import {chunkArray} from '../utils/ArrayUtils';
import {logBatch, logQuery} from './CassandraDevLogger';
import {getIsDev} from './CassandraMetaRegistry';
import type {CassandraParams, KvQueryMeta, PreparedQuery, QueryTemplate} from './CassandraTypes';
import {
assertNoUndefinedParams,
chunkArray,
getStatementMeta,
normalizeExecuteArgs,
normalizeInParams,
} from './CassandraTypes';
import {assertNoUndefinedParams, getStatementMeta, normalizeExecuteArgs, normalizeInParams} from './CassandraTypes';
const DEFAULT_MAX_PARTITION_KEYS_PER_QUERY = 100;
@@ -366,14 +366,3 @@ export function assertNoUndefinedParams(params: Record<string, unknown>): void {
}
}
}
export function chunkArray<T>(items: Array<T>, size: number): Array<Array<T>> {
const chunks: Array<Array<T>> = [];
if (size <= 0) {
throw new Error('Chunk size must be greater than 0');
}
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
export interface AdminArchiveRow {
subject_type: 'user' | 'guild';
subject_type: ArchiveSubjectType;
subject_id: bigint;
archive_id: bigint;
requested_by: bigint;
@@ -10,6 +10,8 @@ import {
DesktopVersionsParam,
DesktopVersionsQuery,
DesktopVersionsResponse,
DownloadChecksumResponse,
DownloadFileResponse,
VersionInfoResponse,
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import type {Context, Hono} from 'hono';
@@ -204,7 +206,8 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
OpenAPI({
operationId: 'download_latest_desktop_version_checksum',
summary: 'Download latest desktop version checksum',
responseSchema: null,
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
@@ -231,8 +234,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
OpenAPI({
operationId: 'download_latest_desktop_version',
summary: 'Download latest desktop version',
responseSchema: null,
statusCode: 200,
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
@@ -288,7 +293,8 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
OpenAPI({
operationId: 'download_desktop_version_checksum',
summary: 'Download desktop version checksum',
responseSchema: null,
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
@@ -315,8 +321,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
OpenAPI({
operationId: 'download_desktop_version',
summary: 'Download desktop version',
responseSchema: null,
statusCode: 200,
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
@@ -340,8 +348,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
OpenAPI({
operationId: 'download_file',
summary: 'Download file',
responseSchema: null,
statusCode: 200,
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
@@ -22,7 +22,7 @@ export function ExperimentController(app: HonoApp) {
description:
'Returns the polling cadence and every experiment assignment resolved for the authenticated user from the instance configuration. Clients revalidate with If-None-Match and receive 304 when nothing changed.',
responseSchema: ExperimentAssignmentsResponse,
statusCode: 200,
statusCode: [200, 304],
security: ['bearerToken', 'sessionToken', 'botToken'],
tags: ['Experiments'],
}),
+6 -5
View File
@@ -2,14 +2,15 @@
import {
GifFeaturedResponse,
GifListResponse,
GifLocaleQuery,
GifRegisterShareRequest,
GifResponse,
GifSearchQuery,
GifSearchSuggestionsResponse,
} from '@fluxer/schema/src/domains/gif/GifSchemas';
import type {Context, MiddlewareHandler} from 'hono';
import {createMiddleware} from 'hono/factory';
import {z} from 'zod';
import {DefaultUserOnly, LoginRequired} from '../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
@@ -61,7 +62,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
OpenAPI({
operationId: `search_${operationSuffix}`,
summary: `Search GIFs${deprecated ? ' (deprecated alias)' : ''}`,
responseSchema: z.array(GifResponse),
responseSchema: GifListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: [...tags],
@@ -109,7 +110,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
OpenAPI({
operationId: `get_trending_${operationSuffix}`,
summary: `Get trending GIFs${deprecated ? ' (deprecated alias)' : ''}`,
responseSchema: z.array(GifResponse),
responseSchema: GifListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: [...tags],
@@ -162,7 +163,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
OpenAPI({
operationId: `get_${operationSuffix}_search_suggestions`,
summary: `Get GIF search suggestions${deprecated ? ' (deprecated alias)' : ''}`,
responseSchema: z.array(z.string()),
responseSchema: GifSearchSuggestionsResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: [...tags],
+14 -27
View File
@@ -10,7 +10,6 @@ import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/dom
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {z} from 'zod';
import {
stripGuildBannerForFeatures,
stripGuildIconForFeatures,
@@ -27,7 +26,7 @@ import type {GuildSticker} from '../models/GuildSticker';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '../user/UserCacheHelpers';
import {mapGuildFeatures} from './GuildFeatureUtils';
export function mapGuildToPartialResponse(guild: Guild): z.infer<typeof GuildPartialResponse> {
export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
const guildId = guild.id.toString();
const iconHash = stripGuildIconForFeatures(guild.iconHash, guild.features);
const bannerHash = stripGuildBannerForFeatures(guild.bannerHash, guild.features);
@@ -56,7 +55,7 @@ export function mapGuildToGuildResponse(
options?: {
permissions?: bigint | null;
},
): z.infer<typeof GuildResponse> {
): GuildResponse {
const iconHash = stripGuildIconForFeatures(guild.iconHash, guild.features);
const bannerHash = stripGuildBannerForFeatures(guild.bannerHash, guild.features);
const splashHash = stripGuildSplashForFeatures(guild.splashHash, guild.features);
@@ -97,7 +96,7 @@ export function mapGuildToGuildResponse(
};
}
export function mapGuildRoleToResponse(role: GuildRole): z.infer<typeof GuildRoleResponse> {
export function mapGuildRoleToResponse(role: GuildRole): GuildRoleResponse {
return {
id: role.id.toString(),
name: role.name,
@@ -110,7 +109,7 @@ export function mapGuildRoleToResponse(role: GuildRole): z.infer<typeof GuildRol
};
}
export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof GuildEmojiResponse> {
export function mapGuildEmojiToResponse(emoji: GuildEmoji): GuildEmojiResponse {
const id = emoji.id.toString();
return {
id,
@@ -120,7 +119,7 @@ export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof Guild
};
}
export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof GuildStickerResponse> {
export function mapGuildStickerToResponse(sticker: GuildSticker): GuildStickerResponse {
const id = sticker.id.toString();
return {
id,
@@ -132,10 +131,7 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof
};
}
function mapMemberWithUser(
member: GuildMember,
userPartial: z.infer<typeof UserPartialResponse>,
): z.infer<typeof GuildMemberResponse> {
function mapMemberWithUser(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
const now = Date.now();
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
return {
@@ -154,7 +150,7 @@ function mapMemberWithUser(
};
}
export function isGuildMemberTimedOut(member?: z.infer<typeof GuildMemberResponse> | null): boolean {
export function isGuildMemberTimedOut(member?: GuildMemberResponse | null): boolean {
if (!member?.communication_disabled_until) {
return false;
}
@@ -166,15 +162,12 @@ export async function mapGuildMemberToResponse(
member: GuildMember,
userCacheService: Pick<UserCacheService, 'getUserPartialResponse'>,
requestCache: RequestCache,
): Promise<z.infer<typeof GuildMemberResponse>> {
): Promise<GuildMemberResponse> {
const userPartial = await getCachedUserPartialResponse({userId: member.userId, userCacheService, requestCache});
return mapMemberWithUser(member, userPartial);
}
function mapEmojiWithUser(
emoji: GuildEmoji,
userPartial: z.infer<typeof UserPartialResponse>,
): z.infer<typeof GuildEmojiWithUserResponse> {
function mapEmojiWithUser(emoji: GuildEmoji, userPartial: UserPartialResponse): GuildEmojiWithUserResponse {
const id = emoji.id.toString();
return {
id,
@@ -189,7 +182,7 @@ export async function mapGuildEmojisWithUsersToResponse(
emojis: Array<GuildEmoji>,
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
requestCache: RequestCache,
): Promise<Array<z.infer<typeof GuildEmojiWithUserResponse>>> {
): Promise<Array<GuildEmojiWithUserResponse>> {
const userIds = [...new Set(emojis.map((emoji) => emoji.creatorId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return emojis
@@ -197,10 +190,7 @@ export async function mapGuildEmojisWithUsersToResponse(
.map((emoji) => mapEmojiWithUser(emoji, userPartials.get(emoji.creatorId)!));
}
function mapStickerWithUser(
sticker: GuildSticker,
userPartial: z.infer<typeof UserPartialResponse>,
): z.infer<typeof GuildStickerWithUserResponse> {
function mapStickerWithUser(sticker: GuildSticker, userPartial: UserPartialResponse): GuildStickerWithUserResponse {
return {
id: sticker.id.toString(),
name: sticker.name,
@@ -216,7 +206,7 @@ export async function mapGuildStickersWithUsersToResponse(
stickers: Array<GuildSticker>,
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
requestCache: RequestCache,
): Promise<Array<z.infer<typeof GuildStickerWithUserResponse>>> {
): Promise<Array<GuildStickerWithUserResponse>> {
const userIds = [...new Set(stickers.map((sticker) => sticker.creatorId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return stickers
@@ -224,10 +214,7 @@ export async function mapGuildStickersWithUsersToResponse(
.map((sticker) => mapStickerWithUser(sticker, userPartials.get(sticker.creatorId)!));
}
function mapBanWithUser(
ban: GuildBan,
userPartial: z.infer<typeof UserPartialResponse>,
): z.infer<typeof GuildBanResponse> {
function mapBanWithUser(ban: GuildBan, userPartial: UserPartialResponse): GuildBanResponse {
return {
user: userPartial,
reason: ban.reason,
@@ -241,7 +228,7 @@ export async function mapGuildBansToResponse(
bans: Array<GuildBan>,
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
requestCache: RequestCache,
): Promise<Array<z.infer<typeof GuildBanResponse>>> {
): Promise<Array<GuildBanResponse>> {
const userIds = [...new Set(bans.map((ban) => ban.userId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return bans
@@ -14,8 +14,12 @@ import {
GuildVanityURLUpdateRequest,
GuildVanityURLUpdateResponse,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {z} from 'zod';
import {
GuildListResponse,
GuildResponse,
GuildVanityURLResponse,
} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {createGuildID} from '../../BrandedTypes';
@@ -68,7 +72,7 @@ export function GuildBaseController(app: HonoApp) {
operationId: 'list_guilds',
summary: 'List current user guilds',
description: 'Requires guilds OAuth scope if using bearer token. Returns all guilds the user is a member of.',
responseSchema: z.array(GuildResponse),
responseSchema: GuildListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
@@ -4,9 +4,9 @@ import {
ChannelCreateRequest,
ChannelPositionUpdateRequest,
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelListResponse, ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {z} from 'zod';
import {createChannelID, createGuildID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
@@ -24,7 +24,7 @@ export function GuildChannelController(app: HonoApp) {
OpenAPI({
operationId: 'list_guild_channels',
summary: 'List guild channels',
responseSchema: z.array(ChannelResponse),
responseSchema: ChannelListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
@@ -1,21 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
GuildIdParam,
GuildIdUserIdParam,
GuildIdUserIdRoleIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {
GuildBanListResponse,
GuildMemberListResponse,
GuildMemberResponse,
} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {
GuildBanCreateRequest,
GuildMemberListQuery,
GuildMemberUpdateRequest,
GuildTransferOwnershipRequest,
GuildTransferOwnershipWithVerificationRequest,
MyGuildMemberUpdateRequest,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {z} from 'zod';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {createGuildID, createRoleID, createUserID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
@@ -36,7 +39,7 @@ export function GuildMemberController(app: HonoApp) {
OpenAPI({
operationId: 'list_guild_members',
summary: 'List guild members',
responseSchema: z.array(GuildMemberResponse),
responseSchema: GuildMemberListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
@@ -194,7 +197,7 @@ export function GuildMemberController(app: HonoApp) {
LoginRequired,
Validator('param', GuildIdParam),
SudoModeMiddleware,
Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema)),
Validator('json', GuildTransferOwnershipWithVerificationRequest),
OpenAPI({
operationId: 'transfer_guild_ownership',
summary: 'Transfer guild ownership',
@@ -227,7 +230,7 @@ export function GuildMemberController(app: HonoApp) {
OpenAPI({
operationId: 'list_guild_bans',
summary: 'List guild bans',
responseSchema: z.array(GuildBanResponse),
responseSchema: GuildBanListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
@@ -7,8 +7,8 @@ import {
GuildRolePositionsRequest,
GuildRoleUpdateRequest,
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
import {z} from 'zod';
import {GuildRoleListResponse, GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
import {createGuildID, createRoleID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {requireOAuth2ScopeForBearer} from '../../middleware/OAuth2ScopeMiddleware';
@@ -29,7 +29,7 @@ export function GuildRoleController(app: HonoApp) {
OpenAPI({
operationId: 'list_guild_roles',
summary: 'List guild roles',
responseSchema: z.array(GuildRoleResponse),
responseSchema: GuildRoleListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
@@ -11,13 +11,18 @@ import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLocke
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
import type {
AuditLogOptions,
AuditLogWebhookResponse,
GuildAuditLogEntryResponse,
GuildAuditLogListResponse,
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import type {
GuildEmojiMetadataResponse,
GuildStickerMetadataResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {ApiContext} from '../../ApiContext';
@@ -50,40 +55,17 @@ import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
interface AuditLogOptions {
channel_id?: string;
count?: number;
delete_member_days?: string;
id?: string;
integration_type?: number;
message_id?: string;
members_removed?: number;
role_name?: string;
type?: number;
inviter_id?: string;
max_age?: number;
max_uses?: number;
temporary?: boolean;
uses?: number;
}
interface GuildAuditLogEntryResponse {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: AuditLogOptions;
interface StoredGuildAuditLogEntryResponse extends Omit<GuildAuditLogEntryResponse, 'changes'> {
changes?: GuildAuditLogChange;
}
interface AuditLogWebhook {
id: string;
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
type: number;
guild_id: string | null;
channel_id: string | null;
name: string;
avatar_hash: string | null;
}
interface StoredGuildAuditLogListResponse extends Omit<GuildAuditLogListResponse, 'audit_log_entries' | 'webhooks'> {
audit_log_entries: Array<StoredGuildAuditLogEntryResponse>;
webhooks: Array<StoredAuditLogWebhookResponse>;
}
interface GuildAuth {
@@ -303,11 +285,7 @@ export class GuildService {
afterLogId?: bigint;
filterUserId?: UserID;
actionType?: AuditLogActionType;
}): Promise<{
audit_log_entries: Array<GuildAuditLogEntryResponse>;
users: Array<UserPartialResponse>;
webhooks: Array<AuditLogWebhook>;
}> {
}): Promise<StoredGuildAuditLogListResponse> {
const {userId, guildId} = params;
const [hasPermission, guild] = await Promise.all([
this.gatewayService.checkPermission({
@@ -334,11 +312,7 @@ export class GuildService {
afterLogId?: bigint;
filterUserId?: UserID;
actionType?: AuditLogActionType;
}): Promise<{
audit_log_entries: Array<GuildAuditLogEntryResponse>;
users: Array<UserPartialResponse>;
webhooks: Array<AuditLogWebhook>;
}> {
}): Promise<StoredGuildAuditLogListResponse> {
const {guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params;
if (beforeLogId !== undefined && afterLogId !== undefined) {
throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER);
@@ -412,7 +386,7 @@ export class GuildService {
};
}
private mapAuditLogToEntry(log: GuildAuditLog): GuildAuditLogEntryResponse {
private mapAuditLogToEntry(log: GuildAuditLog): StoredGuildAuditLogEntryResponse {
return {
id: log.logId.toString(),
action_type: log.actionType,
@@ -532,7 +506,7 @@ export class GuildService {
return {webhooks: foundWebhooks};
}
private buildAuditLogWebhookResponses(webhooks: Array<Webhook>): Array<AuditLogWebhook> {
private buildAuditLogWebhookResponses(webhooks: Array<Webhook>): Array<StoredAuditLogWebhookResponse> {
return webhooks.map((webhook) => ({
id: webhook.id.toString(),
type: webhook.type,
@@ -188,7 +188,7 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
}
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
lookup,
@@ -7,15 +7,25 @@ import {
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
type ExperimentDeliveryConfig,
ExperimentDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
type InstanceAppPublic,
type InstanceBranding,
type InstanceCaptchaProvider,
InstanceCaptchaProviderSchema,
type InstanceCommunity,
type InstanceRegistration,
type InstanceRegistrationMode,
InstanceRegistrationModeSchema,
type InstanceServices,
type InstanceSetup,
} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
import {Config} from '../Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '../config/APIConfig';
@@ -56,47 +66,10 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
voice_e2ee_scope: 'guild_feature_only',
};
function cloneDefaultVoiceNoiseSuppressionConfig(): VoiceNoiseSuppressionConfig {
return {
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled_backends: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.enabled_backends],
included_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.included_user_ids],
excluded_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.excluded_user_ids],
guild_overrides: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.guild_overrides],
};
}
export type InstanceRegistrationConfig = InstanceRegistration;
function cloneDefaultExperimentDeliveryConfig(): ExperimentDeliveryConfig {
return {...DEFAULT_EXPERIMENT_DELIVERY_CONFIG};
}
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
export interface InstanceRegistrationConfig {
mode: InstanceRegistrationMode;
admin_registration_urls_enabled: boolean;
}
export interface InstanceBrandingConfig {
product_name: string;
icon_url: string | null;
symbol_url: string | null;
logo_url: string | null;
wordmark_url: string | null;
favicon_url: string | null;
theme_color: string | null;
}
interface InstanceAppPublicConfig {
branding: InstanceBrandingConfig;
setup: {
configured: boolean;
};
legal: {
terms_url: string | null;
privacy_url: string | null;
};
registration: {
collect_date_of_birth: boolean;
};
interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
setup: Pick<InstanceSetup, 'configured'>;
}
export type InstancePremiumMode = 'mirror' | 'everyone';
@@ -115,19 +88,6 @@ export interface InstancePolicyConfig {
deferred_phone_gate_member_threshold: number;
}
interface InstanceCommunityPublicConfig {
single_community: boolean;
single_community_guild_id: string | null;
direct_messages_disabled: boolean;
}
interface InstanceServicesPublicConfig {
gif_enabled: boolean;
youtube_enabled: boolean;
bluesky_enabled: boolean;
}
export type InstanceCaptchaProvider = 'hcaptcha' | 'turnstile' | 'none';
type InstanceEmailProvider = 'smtp' | 'none';
interface InstanceGifIntegrationConfig {
@@ -341,7 +301,7 @@ function isStringArray(value: unknown): value is Array<string> {
}
function isRegistrationMode(value: unknown): value is InstanceRegistrationMode {
return value === 'open' || value === 'approval' || value === 'closed';
return InstanceRegistrationModeSchema.safeParse(value).success;
}
function normalizeNullableString(value: unknown): string | null {
@@ -542,7 +502,7 @@ const DEFAULT_INSTANCE_MEDIA_CONFIG: InstanceMediaConfig = {
};
function isCaptchaProvider(value: unknown): value is InstanceCaptchaProvider {
return value === 'hcaptcha' || value === 'turnstile' || value === 'none';
return InstanceCaptchaProviderSchema.safeParse(value).success;
}
function isEmailProvider(value: unknown): value is InstanceEmailProvider {
@@ -1079,16 +1039,16 @@ export class InstanceConfigRepository {
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
if (!raw) {
return cloneDefaultVoiceNoiseSuppressionConfig();
return VoiceNoiseSuppressionConfigSchema.parse({});
}
const parsed = parseJsonRecord(raw);
if (!parsed) {
return cloneDefaultVoiceNoiseSuppressionConfig();
return VoiceNoiseSuppressionConfigSchema.parse({});
}
const result = VoiceNoiseSuppressionConfigSchema.safeParse({...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG, ...parsed});
const result = VoiceNoiseSuppressionConfigSchema.safeParse(parsed);
if (!result.success) {
Logger.error({error: result.error}, 'Invalid voice noise suppression config');
return cloneDefaultVoiceNoiseSuppressionConfig();
return VoiceNoiseSuppressionConfigSchema.parse({});
}
return result.data;
}
@@ -1100,16 +1060,16 @@ export class InstanceConfigRepository {
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
if (!raw) {
return cloneDefaultExperimentDeliveryConfig();
return ExperimentDeliveryConfigSchema.parse({});
}
const parsed = parseJsonRecord(raw);
if (!parsed) {
return cloneDefaultExperimentDeliveryConfig();
return ExperimentDeliveryConfigSchema.parse({});
}
const result = ExperimentDeliveryConfigSchema.safeParse({...DEFAULT_EXPERIMENT_DELIVERY_CONFIG, ...parsed});
const result = ExperimentDeliveryConfigSchema.safeParse(parsed);
if (!result.success) {
Logger.error({error: result.error}, 'Invalid experiment delivery config');
return cloneDefaultExperimentDeliveryConfig();
return ExperimentDeliveryConfigSchema.parse({});
}
return result.data;
}
@@ -1156,7 +1116,7 @@ export class InstanceConfigRepository {
}
async setAppPublicConfig(config: {
branding?: Partial<InstanceBrandingConfig>;
branding?: Partial<InstanceBranding>;
setup?: Partial<InstanceAppPublicConfig['setup']>;
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
@@ -1468,7 +1428,7 @@ export class InstanceConfigRepository {
};
}
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunityPublicConfig> {
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
const policy = await this.getInstancePolicyConfig();
return {
single_community: policy.single_community_enabled,
@@ -1477,7 +1437,7 @@ export class InstanceConfigRepository {
};
}
async getResolvedServicesConfig(): Promise<InstanceServicesPublicConfig> {
async getResolvedServicesConfig(): Promise<InstanceServices> {
const [policy, gif, youtubeApiKey, bluesky] = await Promise.all([
this.getInstancePolicyConfig(),
this.getEffectiveGifConfig(),
@@ -3,10 +3,11 @@
import {ChannelIdParam, GuildIdParam, InviteCodeParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ChannelInviteCreateRequest,
InviteMetadataListResponse,
InviteMetadataResponseSchema,
InviteResponseSchema,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {z} from 'zod';
import {createChannelID, createGuildID, createInviteCode} from '../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
@@ -129,7 +130,7 @@ export function InviteController(app: HonoApp) {
summary: 'List channel invites',
description:
'Retrieves all currently active invites for the specified channel, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the channel. Returns an array of invite metadata objects.',
responseSchema: z.array(InviteMetadataResponseSchema),
responseSchema: InviteMetadataListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Invites'],
@@ -152,7 +153,7 @@ export function InviteController(app: HonoApp) {
summary: 'List guild invites',
description:
'Retrieves all currently active invites across all channels in the specified guild, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the guild. Returns an array of invite metadata objects.',
responseSchema: z.array(InviteMetadataResponseSchema),
responseSchema: InviteMetadataListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Invites'],
+7 -30
View File
@@ -10,7 +10,6 @@ import type {
GuildInviteMetadataResponse,
GuildInviteResponse,
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import type {z} from 'zod';
import type {ChannelID, GuildID} from '../BrandedTypes';
import type {IGatewayService} from '../infrastructure/IGatewayService';
import type {UserCacheService} from '../infrastructure/UserCacheService';
@@ -23,21 +22,8 @@ interface MapInviteToGuildInviteResponseParams {
invite: Invite;
userCacheService: UserCacheService;
requestCache: RequestCache;
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
getGuildCounts: (guildId: GuildID) => Promise<{
memberCount: number;
presenceCount: number;
}>;
gatewayService: IGatewayService;
}
interface MapInviteToGuildInviteMetadataResponseParams {
invite: Invite;
userCacheService: UserCacheService;
requestCache: RequestCache;
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
getGuildResponse: (guildId: GuildID) => Promise<GuildPartialResponse>;
getGuildCounts: (guildId: GuildID) => Promise<{
memberCount: number;
presenceCount: number;
@@ -49,16 +35,7 @@ interface MapInviteToGroupDmInviteResponseParams {
invite: Invite;
userCacheService: UserCacheService;
requestCache: RequestCache;
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
}
interface MapInviteToGroupDmInviteMetadataResponseParams {
invite: Invite;
userCacheService: UserCacheService;
requestCache: RequestCache;
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
}
@@ -71,7 +48,7 @@ export async function mapInviteToGuildInviteResponse({
getGuildResponse,
getGuildCounts,
gatewayService,
}: MapInviteToGuildInviteResponseParams): Promise<z.infer<typeof GuildInviteResponse>> {
}: MapInviteToGuildInviteResponseParams): Promise<GuildInviteResponse> {
if (!invite.guildId) {
throw new UnknownInviteError();
}
@@ -117,7 +94,7 @@ export async function mapInviteToGuildInviteMetadataResponse({
getGuildResponse,
getGuildCounts,
gatewayService,
}: MapInviteToGuildInviteMetadataResponseParams): Promise<z.infer<typeof GuildInviteMetadataResponse>> {
}: MapInviteToGuildInviteResponseParams): Promise<GuildInviteMetadataResponse> {
const baseResponse = await mapInviteToGuildInviteResponse({
invite,
userCacheService,
@@ -143,7 +120,7 @@ export async function mapInviteToGroupDmInviteResponse({
getChannelResponse,
getChannelSystem,
getChannelMemberCount,
}: MapInviteToGroupDmInviteResponseParams): Promise<z.infer<typeof GroupDmInviteResponse>> {
}: MapInviteToGroupDmInviteResponseParams): Promise<GroupDmInviteResponse> {
if (!invite.channelId) {
throw new UnknownInviteError();
}
@@ -195,7 +172,7 @@ export async function mapInviteToGroupDmInviteMetadataResponse({
getChannelResponse,
getChannelSystem,
getChannelMemberCount,
}: MapInviteToGroupDmInviteMetadataResponseParams): Promise<z.infer<typeof GroupDmInviteMetadataResponse>> {
}: MapInviteToGroupDmInviteResponseParams): Promise<GroupDmInviteMetadataResponse> {
const baseResponse = await mapInviteToGroupDmInviteResponse({
invite,
userCacheService,
@@ -4,12 +4,13 @@ import {Headers} from '@fluxer/constants/src/Headers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
import {Config} from '../Config';
import type {InstanceCaptchaEffectiveConfig, InstanceCaptchaProvider} from '../instance/InstanceConfigRepository';
import type {InstanceCaptchaEffectiveConfig} from '../instance/InstanceConfigRepository';
import type {User} from '../models/User';
import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService';
import type {HonoEnv} from '../types/HonoEnv';
@@ -78,7 +78,7 @@ export function ResponseType<T extends ZodType>(
): MiddlewareHandler<HonoEnv> {
const {skipValidation = false, allowNoContent = false} = options ?? {};
return async (ctx, next) => {
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
ctx.set('responseSchema', schema);
await next();
if (skipValidation || !Config.dev.validateResponses) {
return;
@@ -90,35 +90,19 @@ export function ResponseType<T extends ZodType>(
};
}
interface OpenAPIMetadata {
operationId: string;
summary: string;
description: string;
responseSchema: ZodType | null;
requestSchema?: ZodType;
requestFormSchema?: ZodType;
requestBodyRequired?: boolean;
statusCode?: number | Array<number>;
security?: SecurityScheme | Array<SecurityScheme>;
tags: string | Array<string>;
deprecated?: boolean;
externalDocs?: {
url: string;
description?: string;
};
}
type SecurityScheme = 'botToken' | 'oauth2Token' | 'bearerToken' | 'sessionToken' | 'adminApiKey';
interface OpenAPIRouteMetadata {
export interface OpenAPIRouteMetadata {
operationId: string;
summary: string;
description: string;
responseSchema: ZodType | null;
responseContentType?: string;
requestSchema?: ZodType;
requestFormSchema?: ZodType;
requestBodyRequired?: boolean;
statusCode?: number | Array<number>;
bodylessStatusCodes?: Array<number>;
security?: SecurityScheme | Array<SecurityScheme>;
tags: string | Array<string>;
deprecated?: boolean;
@@ -130,6 +114,7 @@ interface OpenAPIRouteMetadata {
interface OpenAPIOptions {
description: string;
responseContentType?: string;
}
function validateOperationId(operationId: string): void {
@@ -140,20 +125,8 @@ function validateOperationId(operationId: string): void {
}
}
function normalizeSecurityToArray(
security?: SecurityScheme | Array<SecurityScheme>,
): Array<SecurityScheme> | undefined {
if (!security) return undefined;
return Array.isArray(security) ? security : [security];
}
function normalizeTagsToArray(tags: string | Array<string>): Array<string> {
return Array.isArray(tags) ? tags : [tags];
}
function normalizeStatusCodeToArray(statusCode?: number | Array<number>): Array<number> | undefined {
if (!statusCode) return undefined;
return Array.isArray(statusCode) ? statusCode : [statusCode];
function toArray<T>(value: T | Array<T>): Array<T> {
return Array.isArray(value) ? value : [value];
}
export function OpenAPI(metadata: OpenAPIRouteMetadata): MiddlewareHandler<HonoEnv>;
@@ -186,33 +159,28 @@ export function OpenAPI(
summary: summary!,
description: options.description,
responseSchema,
responseContentType: options.responseContentType,
tags: [],
};
} else {
metadata = operationIdOrMetadata;
}
validateOperationId(metadata.operationId);
const {statusCode, security, tags, deprecated, externalDocs} = metadata;
const {statusCode, security, tags, bodylessStatusCodes, responseContentType} = metadata;
const schema = metadata.responseSchema;
const fullMetadata: OpenAPIRouteMetadata = {
...metadata,
statusCode: statusCode === undefined ? undefined : toArray(statusCode),
security: security === undefined ? undefined : toArray(security),
tags: toArray(tags),
};
const mediaType = responseContentType?.split(';', 1)[0]?.trim().toLowerCase();
const hasJsonResponse = mediaType === undefined || mediaType === 'application/json';
return async (ctx, next) => {
const fullMetadata: OpenAPIMetadata = {
operationId: metadata.operationId,
summary: metadata.summary,
description: metadata.description,
responseSchema: schema,
requestSchema: metadata.requestSchema,
requestFormSchema: metadata.requestFormSchema,
requestBodyRequired: metadata.requestBodyRequired,
statusCode: statusCode ? normalizeStatusCodeToArray(statusCode) : undefined,
security: security ? normalizeSecurityToArray(security) : undefined,
tags: normalizeTagsToArray(tags),
deprecated,
externalDocs,
};
ctx.set('openapiMetadata' as keyof HonoEnv['Variables'], fullMetadata);
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
ctx.set('openapiMetadata', fullMetadata);
ctx.set('responseSchema', schema);
await next();
if (!schema || !Config.dev.validateResponses) {
if (!schema || !hasJsonResponse || !Config.dev.validateResponses || bodylessStatusCodes?.includes(ctx.res.status)) {
return;
}
await validateAndRewriteResponse(ctx, schema);
@@ -3,7 +3,7 @@
import {Logger} from '@fluxer/logger/src/Logger';
import {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {Hono} from 'hono';
import {afterAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
import {z} from 'zod';
import {Config} from '../../Config';
import type {HonoEnv} from '../../types/HonoEnv';
@@ -12,12 +12,14 @@ import {OpenAPI, ResponseType} from '../ResponseTypeMiddleware';
const SnowflakeResponse = z.object({id: SnowflakeType});
describe('ResponseTypeMiddleware', () => {
const originalValidateResponses = Config.dev.validateResponses;
beforeEach(() => {
Config.dev.validateResponses = true;
});
afterAll(() => {
Config.dev.validateResponses = true;
afterEach(() => {
Config.dev.validateResponses = originalValidateResponses;
});
test('serializes SnowflakeType response transforms as JSON strings', async () => {
@@ -50,9 +52,25 @@ describe('ResponseTypeMiddleware', () => {
expect(await response.json()).toEqual({id: '123456789012345678'});
});
test('rejects mismatching responses while validation is enabled', async () => {
test.each([
undefined,
'application/json',
'application/json; charset=utf-8',
'Application/JSON; charset=utf-8',
])('rejects mismatching responses while validation is enabled (content type: %s)', async (responseContentType) => {
const app = new Hono<HonoEnv>();
app.get('/snowflake', ResponseType(SnowflakeResponse), (ctx) => ctx.json({id: 'not-a-snowflake'}));
const middleware =
responseContentType === undefined
? ResponseType(SnowflakeResponse)
: OpenAPI({
operationId: 'get_invalid_snowflake_test',
summary: 'Get invalid snowflake',
description: 'Returns an invalid snowflake to verify JSON response validation.',
responseSchema: SnowflakeResponse,
responseContentType,
tags: ['Tests'],
});
app.get('/snowflake', middleware, (ctx) => ctx.json({id: 'not-a-snowflake'}));
const errorLoggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
try {
@@ -106,4 +124,67 @@ describe('ResponseTypeMiddleware', () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({id: 'not-a-snowflake', extra: 'kept'});
});
test('normalizes route metadata while preserving anonymous access and bodyless statuses', async () => {
const app = new Hono<HonoEnv>();
app.get(
'/metadata',
OpenAPI({
operationId: 'get_metadata_test',
summary: 'Get route metadata',
description: 'Exposes normalized metadata for this route.',
responseSchema: z.object({
statusCode: z.array(z.number()),
bodylessStatusCodes: z.array(z.number()),
security: z.array(z.string()),
tags: z.array(z.string()),
}),
statusCode: [200, 302],
bodylessStatusCodes: [302],
security: [],
tags: 'Tests',
}),
(ctx) => {
const metadata = ctx.get('openapiMetadata');
return ctx.json({
statusCode: metadata?.statusCode,
bodylessStatusCodes: metadata?.bodylessStatusCodes,
security: metadata?.security,
tags: metadata?.tags,
});
},
);
const response = await app.request('/metadata');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
statusCode: [200, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Tests'],
});
});
test('preserves raw JSON file bytes under a binary response contract', async () => {
const body = '{ "file": "contents" }';
const app = new Hono<HonoEnv>();
app.get(
'/artifact',
OpenAPI({
operationId: 'get_artifact_test',
summary: 'Get artifact file',
description: 'Returns file bytes without treating JSON artifacts as structured API responses.',
responseSchema: z.file(),
responseContentType: '*/*',
statusCode: 200,
tags: ['Tests'],
}),
() => new Response(body, {headers: {'Content-Type': 'application/json'}}),
);
const response = await app.request('/artifact');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/json');
expect(await response.text()).toBe(body);
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {GuildID, UserID} from '../BrandedTypes';
import type {GuildAuditLogRow} from '../database/types/GuildTypes';
@@ -11,7 +12,7 @@ export class GuildAuditLog {
readonly logId: bigint;
readonly userId: UserID;
readonly targetId: string | null;
readonly actionType: number;
readonly actionType: AuditLogActionType;
readonly reason: string | null;
readonly options: Map<string, string>;
readonly changes: GuildAuditLogChange | null;
+4 -5
View File
@@ -23,7 +23,6 @@ import {
RevokeRequestForm,
TokenRequest,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {z} from 'zod';
import {Config} from '../Config';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '../middleware/AuthMiddleware';
import {requireOAuth2BearerToken, requireOAuth2Scope} from '../middleware/OAuth2ScopeMiddleware';
@@ -119,7 +118,7 @@ export function OAuth2Controller(app: HonoApp) {
'User grants permission for an OAuth2 application to access authorized scopes. Used in authorization code flow to complete the authorization process after user review.',
}),
async (ctx) => {
const body: z.infer<typeof AuthorizeConsentRequest> = ctx.req.valid('json');
const body: AuthorizeConsentRequest = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(
await ctx.get('oauth2RequestService').authorizeConsent({
@@ -263,13 +262,13 @@ export function OAuth2Controller(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
OpenAPI({
operationId: 'get_current_user_applications',
summary: 'List current user applications',
summary: 'Get current bot application',
responseSchema: ApplicationsMeResponse,
statusCode: 200,
security: [],
security: ['botToken'],
tags: ['OAuth2'],
description:
'Lists all OAuth2 applications registered by the authenticated user. Includes application credentials and metadata. Requires valid OAuth2 access token.',
'Retrieves the application associated with the authenticated bot, including its owner and bot profile. Requires a valid bot token.',
}),
async (ctx) => {
const response = await ctx
+8 -3
View File
@@ -1,11 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type UserAuthenticatorType, UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import type {
ApplicationBotResponse,
ApplicationResponse,
BotProfileResponse,
BotTokenResetResponse,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import {stripBannerForUser} from '../infrastructure/AssetEntitlementUtils';
import type {Application} from '../models/Application';
import type {User} from '../models/User';
import {mapUserToPartialResponse} from '../user/UserMappers';
import type {ApplicationBotResponse, ApplicationResponse} from './OAuth2Types';
function getActiveAuthenticatorTypes(user: User): Array<UserAuthenticatorType> {
return Array.from(user.authenticatorTypes ?? []).filter(
@@ -63,14 +68,14 @@ export function mapApplicationToResponse(
return baseResponse;
}
export function mapBotTokenResetResponse(user: User, token: string) {
export function mapBotTokenResetResponse(user: User, token: string): BotTokenResetResponse {
return {
token,
bot: mapBotUserToResponse(user),
};
}
export function mapBotProfileToResponse(user: User) {
export function mapBotProfileToResponse(user: User): BotProfileResponse {
const partial = mapUserToPartialResponse(user);
return {
id: partial.id,
@@ -33,7 +33,6 @@ import type {
TokenRequest,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
import type {ApiContext} from '../ApiContext';
import type {SudoVerificationBody} from '../auth/services/SudoVerificationService';
import {requireSudoMode} from '../auth/services/SudoVerificationService';
@@ -74,7 +73,7 @@ export class OAuth2RequestService {
) {}
async tokenExchange(params: {
form: z.infer<typeof TokenRequest>;
form: TokenRequest;
authorizationHeader?: string;
logPrefix: string;
}): Promise<OAuth2TokenResponse> {
@@ -149,7 +148,7 @@ export class OAuth2RequestService {
};
}
async revoke(params: {form: z.infer<typeof RevokeRequestForm>; authorizationHeader?: string}): Promise<void> {
async revoke(params: {form: RevokeRequestForm; authorizationHeader?: string}): Promise<void> {
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
params.authorizationHeader,
params.form.client_id,
@@ -165,7 +164,7 @@ export class OAuth2RequestService {
}
async introspect(params: {
form: z.infer<typeof IntrospectRequestForm>;
form: IntrospectRequestForm;
authorizationHeader?: string;
}): Promise<OAuth2IntrospectResponse> {
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
@@ -194,7 +193,7 @@ export class OAuth2RequestService {
}
async authorizeConsent(params: {
body: z.infer<typeof AuthorizeConsentRequest>;
body: AuthorizeConsentRequest;
userId: UserID;
requestCache: RequestCache;
}): Promise<OAuth2ConsentResponse> {
-26
View File
@@ -1,26 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserAuthenticatorType} from '@fluxer/constants/src/UserConstants';
export interface ApplicationBotResponse {
id: string;
username: string;
discriminator: string;
avatar?: string | null;
banner?: string | null;
bio: string | null;
token?: string;
mfa_enabled?: boolean;
authenticator_types?: Array<UserAuthenticatorType>;
flags: number;
}
export interface ApplicationResponse {
id: string;
name: string;
redirect_uris: Array<string>;
bot_public: boolean;
bot_require_code_grant: boolean;
client_secret?: string;
bot?: ApplicationBotResponse;
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -3,7 +3,7 @@
import {createHash} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {type ClientHttp2Session, connect, constants} from 'node:http2';
import {importPKCS8, SignJWT} from 'jose';
import {type CryptoKey, importPKCS8, SignJWT} from 'jose';
import {Config} from '../Config';
import type {PushProviderEnvironment} from '../config/APIConfig';
import {Logger} from '../Logger';
@@ -45,7 +45,7 @@ interface Http2Response {
}
const providerTokenCache = new Map<string, CachedProviderToken>();
const apnsSigningKeys = new Map<string, ReturnType<typeof importPKCS8>>();
const apnsSigningKeys = new Map<string, Promise<CryptoKey>>();
const apnsSessions = new Map<string, ClientHttp2Session>();
export async function sendApnsPush(params: SendApnsPushParams): Promise<SendApnsPushResult> {
@@ -134,7 +134,7 @@ export async function ensureApnsSigningKey(): Promise<void> {
Logger.info('APNs signing key loaded');
}
async function apnsSigningKey(privateKey: string): Promise<Awaited<ReturnType<typeof importPKCS8>>> {
async function apnsSigningKey(privateKey: string): Promise<CryptoKey> {
const cached = apnsSigningKeys.get(privateKey);
if (cached) return await cached;
const pending = importPKCS8(privateKey, 'ES256');
+3 -60
View File
@@ -5,9 +5,12 @@ import type {MessageAttachment, MessageEmbed, MessageStickerItem} from '../datab
import type {
DSAReportEmailVerificationRow,
DSAReportTicketRow,
IARSubmissionRow,
MessageReportSubmissionByReporterRow,
} from '../database/types/ReportTypes';
export type {IARMessageContextRow, IARSubmissionRow} from '../database/types/ReportTypes';
export enum ReportStatus {
PENDING = 0,
RESOLVED = 1,
@@ -28,29 +31,6 @@ export function reportStatusToString(status: ReportStatus | number): string {
return REPORT_STATUS_STRINGS[status as ReportStatus] ?? 'unknown';
}
type MentionCollection = ReadonlyArray<bigint> | Set<bigint> | null | undefined;
export interface IARMessageContextRow {
message_id: bigint;
channel_id: bigint | null;
author_id: bigint;
author_username: string;
author_discriminator: number;
author_avatar_hash: string | null;
content: string | null;
timestamp: Date;
edited_timestamp: Date | null;
type: number;
flags: number;
mention_everyone: boolean;
mention_users: MentionCollection;
mention_roles: MentionCollection;
mention_channels: MentionCollection;
attachments: Array<MessageAttachment> | null;
embeds: Array<MessageEmbed> | null;
sticker_items: Array<MessageStickerItem> | null;
}
export interface IARMessageContext {
messageId: MessageID;
channelId: ChannelID | null;
@@ -72,43 +52,6 @@ export interface IARMessageContext {
stickers: Array<MessageStickerItem>;
}
export interface IARSubmissionRow {
report_id: bigint;
reporter_id: bigint | null;
reporter_email: string | null;
reporter_full_legal_name: string | null;
reporter_country_of_residence: string | null;
reported_at: Date;
status: number;
report_type: number;
category: string;
additional_info: string | null;
reported_user_id: bigint | null;
reported_user_avatar_hash: string | null;
reported_guild_id: bigint | null;
reported_guild_name: string | null;
reported_guild_icon_hash: string | null;
reported_message_id: bigint | null;
reported_channel_id: bigint | null;
reported_channel_name: string | null;
message_context: Array<IARMessageContextRow> | null;
guild_context_id: bigint | null;
resolved_at: Date | null;
resolved_by_admin_id: bigint | null;
public_comment: string | null;
audit_log_reason: string | null;
reported_guild_invite_code: string | null;
reported_guild_nsfw?: boolean | null;
reported_guild_content_warning_level?: number | null;
reported_guild_content_warning_text?: string | null;
reported_channel_nsfw_override?: boolean | null;
reported_channel_content_warning_level?: number | null;
reported_channel_content_warning_text?: string | null;
reported_channel_effective_nsfw?: boolean | null;
reported_channel_effective_content_warning_level?: number | null;
reported_channel_effective_content_warning_text?: string | null;
}
export interface IARSubmission {
reportId: ReportID;
reporterId: UserID | null;
@@ -6,7 +6,7 @@ import {StripeWebhookSignatureMissingError} from '@fluxer/errors/src/domains/pay
import {GiftCodeParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
CreateCheckoutSessionRequest,
GiftCodeMetadataResponse,
GiftCodeMetadataListResponse,
GiftCodeResponse,
} from '@fluxer/schema/src/domains/premium/GiftCodeSchemas';
import {
@@ -22,7 +22,7 @@ import {
UrlResponse,
WebhookReceivedResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {z} from 'zod';
import {Config} from '../Config';
import {DefaultUserOnly, LoginRequired} from '../middleware/AuthMiddleware';
import {CaptchaMiddleware} from '../middleware/CaptchaMiddleware';
@@ -251,7 +251,7 @@ export function StripeController(app: HonoApp) {
operationId: 'list_user_gifts',
summary: 'List user gifts',
description: 'Lists all gift codes created by the authenticated user.',
responseSchema: z.array(GiftCodeMetadataResponse),
responseSchema: GiftCodeMetadataListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Users',
+4 -1
View File
@@ -5,6 +5,7 @@ import type {IEmailService} from '@pkgs/email/src/IEmailService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import type {Hono} from 'hono';
import type {ZodType} from 'zod';
import type {ApiContext} from '../ApiContext';
import type {AdminService} from '../admin/AdminService';
import type {AdminApiKeyService} from '../admin/services/AdminApiKeyService';
@@ -47,6 +48,7 @@ import type {InviteRequestService} from '../invite/InviteRequestService';
import type {InviteService} from '../invite/InviteService';
import type {LimitConfigService} from '../limits/LimitConfigService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {OpenAPIRouteMetadata} from '../middleware/ResponseTypeMiddleware';
import type {AuthSession} from '../models/AuthSession';
import type {User} from '../models/User';
import type {ApplicationService} from '../oauth/ApplicationService';
@@ -91,7 +93,8 @@ export interface HonoEnv {
user: User;
requestId?: string;
clientIpResolution?: ClientIpResolution;
responseSchema: unknown;
responseSchema: ZodType | null;
openapiMetadata?: OpenAPIRouteMetadata;
adminService: AdminService;
adminArchiveService: AdminArchiveService;
adminApiKeyService: AdminApiKeyService;
+3 -19
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HarvestStatusResponseSchema} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {z} from 'zod';
import type {HarvestStatus, HarvestStatusResponse} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {UserID} from '../BrandedTypes';
import type {UserHarvestRow} from '../database/types/UserTypes';
@@ -51,20 +50,7 @@ export class UserHarvest {
};
}
toResponse(): {
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
started_at: string | null;
completed_at: string | null;
failed_at: string | null;
file_size: string | null;
progress_percent: number;
progress_step: string | null;
error_message: string | null;
download_url_expires_at: string | null;
expires_at: string | null;
} {
toResponse(): HarvestStatusResponse {
return {
harvest_id: this.harvestId.toString(),
status: this.getStatus(),
@@ -81,12 +67,10 @@ export class UserHarvest {
};
}
getStatus(): 'pending' | 'processing' | 'completed' | 'failed' {
getStatus(): HarvestStatus {
if (this.failedAt) return 'failed';
if (this.completedAt) return 'completed';
if (this.startedAt) return 'processing';
return 'pending';
}
}
export type UserHarvestResponse = z.infer<typeof HarvestStatusResponseSchema>;
-140
View File
@@ -1,140 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MAX_GUILDS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {Locales} from '@fluxer/constants/src/Locales';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {DEFAULT_GUILD_FOLDER_ICON, GuildFolderIcons, ThemeTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import {
isValidSyncedPreferencesEncoding,
SYNCED_PREFERENCES_MAX_ENCODED_LENGTH,
} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
import {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {DateTimeType} from '@fluxer/schema/src/primitives/QueryValidators';
import {ColorType, createStringType, Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const SettableStatusTypes = {
ONLINE: StatusTypes.ONLINE,
DND: StatusTypes.DND,
IDLE: StatusTypes.IDLE,
INVISIBLE: StatusTypes.INVISIBLE,
} as const;
const StatusTypeValues = Object.values(SettableStatusTypes) as Array<ValueOf<typeof SettableStatusTypes>>;
const ThemeTypeValues = Object.values(ThemeTypes) as Array<ValueOf<typeof ThemeTypes>>;
const LocaleValues = Object.values(Locales) as Array<ValueOf<typeof Locales>>;
const GuildFolderIconValues = Object.values(GuildFolderIcons) as Array<ValueOf<typeof GuildFolderIcons>>;
const StatusTypeSchema = z.enum(
StatusTypeValues as [ValueOf<typeof SettableStatusTypes>, ...Array<ValueOf<typeof SettableStatusTypes>>],
);
const ThemeTypeSchema = z.enum(ThemeTypeValues as [ValueOf<typeof ThemeTypes>, ...Array<ValueOf<typeof ThemeTypes>>]);
const LocaleSchema = z.enum(LocaleValues as [ValueOf<typeof Locales>, ...Array<ValueOf<typeof Locales>>]);
const GuildFolderIconSchema = z.enum(
GuildFolderIconValues as [ValueOf<typeof GuildFolderIcons>, ...Array<ValueOf<typeof GuildFolderIcons>>],
);
export const UserSettingsUpdateRequest = z
.object({
flags: z.number().int().describe('Bitfield of user settings flags'),
status: StatusTypeSchema.describe('Current online status (online, idle, dnd, invisible)'),
status_resets_at: DateTimeType.nullish().describe('When the status should reset'),
status_resets_to: StatusTypeSchema.nullish().describe('Status to reset to after timer'),
theme: ThemeTypeSchema.describe('UI theme preference (dark or light)'),
guild_positions: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Ordered array of guild IDs for sidebar positioning'),
locale: LocaleSchema.describe('User language/locale preference'),
restricted_guilds: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs where DMs from members are restricted'),
bot_restricted_guilds: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs where DMs from bots are restricted'),
default_guilds_restricted: z.boolean().describe('Default DM restriction for new guilds'),
bot_default_guilds_restricted: z.boolean().describe('Default bot DM restriction for new guilds'),
inline_attachment_media: z.boolean().describe('Auto-display images and videos inline'),
inline_embed_media: z.boolean().describe('Auto-display embedded media inline'),
gif_auto_play: z.boolean().describe('Auto-play GIFs when visible'),
render_embeds: z.boolean().describe('Show link embeds in messages'),
render_reactions: z.boolean().describe('Show reactions on messages'),
animate_emoji: z.boolean().describe('Animate custom emoji'),
animate_stickers: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Sticker animation setting (0=never, 1=on hover, 2=always)'),
render_spoilers: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Spoiler display setting (0=hidden, 1=on hover, 2=always)'),
message_display_compact: z.boolean().describe('Use compact message display mode'),
friend_source_flags: Int32Type.describe('Bitfield for friend request source permissions'),
incoming_call_flags: Int32Type.describe('Bitfield for incoming call permissions'),
group_dm_add_permission_flags: Int32Type.describe('Bitfield for group DM add permissions'),
guild_folders: z
.array(
z.object({
id: z.number().int().min(-1).describe('Unique folder identifier (-1 for uncategorized)'),
name: createStringType(0, 100).nullish().describe('Folder display name'),
color: ColorType.nullish().default(0x000000).describe('Folder color as integer'),
flags: Int32Type.default(0).describe('Bitfield for guild folder display behaviour'),
icon: GuildFolderIconSchema.default(DEFAULT_GUILD_FOLDER_ICON).describe('Selected icon for the guild folder'),
guild_ids: z
.array(SnowflakeType)
.transform((ids) => [...new Set(ids)])
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
.describe('Guild IDs contained in this folder'),
}),
)
.max(100)
.describe('Array of guild folder configurations'),
custom_status: CustomStatusPayload.nullish().describe('Custom status with text and emoji'),
afk_timeout: z.number().int().min(60).max(600).describe('AFK timeout in seconds (60-600)'),
time_format: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Time format preference (0=12h, 1=24h, 2=relative)'),
developer_mode: z.boolean().describe('Enable developer mode features'),
trusted_domains: z
.array(z.string().min(1).max(253))
.max(1000)
.describe('Trusted external link domains. Use "*" to trust all domains.'),
default_hide_muted_channels: z.boolean().describe('Hide muted channels by default in new guilds'),
sensitive_content_friend_dm_filter: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Sensitive media filter level for DMs from friends (0=show, 1=blur, 2=block)'),
sensitive_content_non_friend_dm_filter: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Sensitive media filter level for DMs from non-friends (0=show, 1=blur, 2=block)'),
sensitive_content_guild_filter: z
.union([z.literal(0), z.literal(1)])
.describe('Sensitive media filter level for community channels (0=show, 1=blur)'),
suppress_unprivileged_self_mentions: z
.boolean()
.describe('Suppress direct mentions and reply mentions from unprivileged users'),
suppress_unprivileged_self_mentions_bypass_user_ids: z
.array(SnowflakeType)
.nullish()
.describe('User IDs that bypass self-mention suppression'),
staff_dm_access_user_ids: z.array(SnowflakeType).nullish().describe('User IDs with Staff DM Access enabled'),
profile_privacy: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.describe('Profile privacy level (0=all guild members, 1=small guilds only (<=200 members), 2=friends only)'),
default_share_voice_activity: z
.boolean()
.describe('Default share_voice_activity applied to new friend relationships'),
synced_preferences: z
.string()
.max(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH)
.refine((value) => isValidSyncedPreferencesEncoding(value), {
message: ValidationErrorCodes.INVALID_FORMAT,
})
.nullish()
.describe(
'Account-wide client preferences as a base64-encoded protobuf snapshot. ' +
'Replaces the entire stored snapshot; pass null to clear it.',
),
})
.partial();
@@ -33,6 +33,7 @@ import {
UserGuildSettingsUpdateRequest,
UserNoteUpdateRequest,
UserProfileQueryRequest,
UserSettingsUpdateRequest,
UserTagCheckQueryRequest,
UserUpdateWithVerificationRequest,
VoiceActivitySharingUpdateRequest,
@@ -72,10 +73,8 @@ import {SudoModeMiddleware} from '../../middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import type {UserUpdateWithVerificationRequestData} from '../services/UserAccountRequestService';
import {getCachedUserPartialResponse} from '../UserCacheHelpers';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse, mapUserToPrivateResponse} from '../UserMappers';
import {UserSettingsUpdateRequest} from '../UserModel';
export function UserAccountController(app: HonoApp) {
app.get(
@@ -125,7 +124,7 @@ export function UserAccountController(app: HonoApp) {
async (ctx) => {
const userAccountRequestService = ctx.get('userAccountRequestService');
const user = ctx.get('user');
const rawBody: UserUpdateWithVerificationRequestData = ctx.req.valid('json');
const rawBody: UserUpdateWithVerificationRequest = ctx.req.valid('json');
return ctx.json(
await userAccountRequestService.updateCurrentUser({
ctx,
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DirectMessagesDisabledError} from '@fluxer/errors/src/domains/channel/DirectMessagesDisabledError';
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelListResponse, ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {z} from 'zod';
import {createChannelID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {GroupDmCreateProtectionMiddleware} from '../../middleware/GroupDmProtectionMiddleware';
@@ -22,7 +22,7 @@ export function UserChannelController(app: HonoApp) {
OpenAPI({
operationId: 'list_private_channels',
summary: 'List private channels',
responseSchema: z.array(ChannelResponse),
responseSchema: ChannelListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
@@ -4,6 +4,7 @@ import {Readable} from 'node:stream';
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
HarvestArchiveResponse,
HarvestCreationResponseSchema,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
@@ -280,8 +281,9 @@ export function UserContentController(app: HonoApp) {
OpenAPI({
operationId: 'download_data_harvest_archive',
summary: 'Download data harvest archive',
responseSchema: null,
statusCode: 200,
responseSchema: HarvestArchiveResponse,
responseContentType: 'application/zip',
statusCode: [200, 206],
security: [],
tags: ['Users'],
description:
@@ -10,9 +10,10 @@ import {
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {
BulkIgnoreFriendRequestsResponse,
RelationshipListResponse,
RelationshipResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {z} from 'zod';
import {createUserID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
@@ -30,7 +31,7 @@ export function UserRelationshipController(app: HonoApp) {
OpenAPI({
operationId: 'list_user_relationships',
summary: 'List user relationships',
responseSchema: z.array(RelationshipResponse),
responseSchema: RelationshipListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
@@ -3,7 +3,6 @@
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {z} from 'zod';
import {createEmojiID, type EmojiID, type UserID} from '../../BrandedTypes';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {contentModerationService} from '../../infrastructure/ContentModerationService';
@@ -12,8 +11,6 @@ import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
type CustomStatusInput = z.infer<typeof CustomStatusPayload>;
interface ValidatedCustomStatus {
text: string | null;
expiresAt: Date | null;
@@ -29,7 +26,7 @@ export class CustomStatusValidator {
private readonly limitConfigService: LimitConfigService,
) {}
async validate(userId: UserID, payload: CustomStatusInput): Promise<ValidatedCustomStatus> {
async validate(userId: UserID, payload: CustomStatusPayload): Promise<ValidatedCustomStatus> {
const text = payload.text ?? null;
contentModerationService.scanText(text, {
userId,
@@ -14,7 +14,6 @@ import type {
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
import * as AuthEmailRevert from '../../auth/AuthEmailRevert';
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import type {IRegistrationRiskEvaluator} from '../../auth/services/IRegistrationRiskEvaluator';
@@ -54,9 +53,8 @@ import type {EmailChangeService} from './EmailChangeService';
import type {UserAccountService} from './UserAccountService';
import type {UserChannelService} from './UserChannelService';
export type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
type UserUpdatePayload = Omit<
UserUpdateWithVerificationRequestData,
UserUpdateWithVerificationRequest,
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
>;
@@ -83,13 +81,13 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequestData,
): UserUpdateWithVerificationRequestData {
body: UserUpdateWithVerificationRequest,
): UserUpdateWithVerificationRequest {
if (canUseProfileTimezone(user)) {
return body;
}
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
return rest as UserUpdateWithVerificationRequestData;
return rest;
}
function hasDefinedUserUpdatePayload(data: UserUpdatePayload): boolean {
@@ -177,7 +175,7 @@ export class UserAccountRequestService {
async updateCurrentUser(params: {
ctx: Context<HonoEnv>;
user: User;
body: UserUpdateWithVerificationRequestData;
body: UserUpdateWithVerificationRequest;
authSession: AuthSession;
}): Promise<UserPrivateResponse> {
const {ctx, body, authSession} = params;
@@ -559,7 +557,7 @@ export class UserAccountRequestService {
}
}
private enforceSuspiciousSelfUpdateAllowance(user: User, body: UserUpdateWithVerificationRequestData): void {
private enforceSuspiciousSelfUpdateAllowance(user: User, body: UserUpdateWithVerificationRequest): void {
const flags = getEffectiveSuspiciousFlags(user);
if (flags === 0) {
return;
@@ -570,7 +568,7 @@ export class UserAccountRequestService {
throw new AccountSuspiciousActivityError(flags);
}
private isAllowedSuspiciousRecoveryUpdate(body: UserUpdateWithVerificationRequestData): boolean {
private isAllowedSuspiciousRecoveryUpdate(body: UserUpdateWithVerificationRequest): boolean {
if (!body.email_token) {
return false;
}
@@ -99,8 +99,6 @@ export class UserAccountSettingsService {
if (data.status_resets_at !== undefined) updatedRowData.status_resets_at = data.status_resets_at;
if (data.status_resets_to !== undefined) updatedRowData.status_resets_to = data.status_resets_to;
if (data.theme !== undefined) {
if (data.theme !== currentSettings.theme) {
}
updatedRowData.theme = data.theme;
}
if (data.locale !== undefined) updatedRowData.locale = data.locale;
@@ -2,16 +2,15 @@
import type {MessageListResponse, MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {
HarvestCreationResponseSchema,
HarvestCreationResponse,
HarvestDownloadUrlResponse,
HarvestStatusResponseSchema,
HarvestStatusResponse,
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {HarvestSelfDataRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {
SavedMessageEntryListResponse,
SavedMessageEntryResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {z} from 'zod';
import type {ChannelID, MessageID, UserID} from '../../BrandedTypes';
import type {IStorageService} from '../../infrastructure/IStorageService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
@@ -19,8 +18,6 @@ import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {Message} from '../../models/Message';
import type {SavedMessageEntry, UserContentService} from './UserContentService';
type HarvestCreationResponse = z.infer<typeof HarvestCreationResponseSchema>;
type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
type HarvestLatestResponse = HarvestStatusResponse | null;
interface UserMentionsParams {
@@ -18,6 +18,7 @@ import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domain
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {HarvestCreationResponse, HarvestStatusResponse} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import type {
BulkDeleteSelfMessagesFilter,
HarvestSelfDataRequest,
@@ -48,10 +49,11 @@ import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {Message} from '../../models/Message';
import type {PushSubscription} from '../../models/PushSubscription';
import {serializeSelfMessageFilter} from '../../worker/utils/SelfMessageFilterPayload';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
import {UserHarvest} from '../UserHarvestModel';
import {UserHarvestRepository} from '../UserHarvestRepository';
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
import {verifyHarvestDownloadToken} from './HarvestDownloadToken';
@@ -438,30 +440,21 @@ export class UserContentService {
await this.deleteMobileDevice(userId, deviceId);
}
async requestDataHarvest(userId: UserID): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
async requestDataHarvest(userId: UserID): Promise<HarvestCreationResponse> {
return this.requestDataHarvestInternal(userId, null);
}
async requestFilteredDataHarvest(params: {userId: UserID; filter: HarvestSelfDataRequest}): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
async requestFilteredDataHarvest(params: {
userId: UserID;
filter: HarvestSelfDataRequest;
}): Promise<HarvestCreationResponse> {
return this.requestDataHarvestInternal(params.userId, params.filter);
}
private async requestDataHarvestInternal(
userId: UserID,
filter: HarvestSelfDataRequest | null,
): Promise<{
harvest_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
}> {
): Promise<HarvestCreationResponse> {
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
const harvestId = await this.snowflakeService.generate();
@@ -486,18 +479,7 @@ export class UserContentService {
harvestId: harvestId.toString(),
...(filter
? {
filter: {
scope: filter.scope,
includeDms: filter.include_dms,
includeDmsClosed: filter.include_dms_closed,
includeGroupDms: filter.include_group_dms,
includeGuilds: filter.include_guilds,
guildFilterMode: filter.guild_filter_mode,
excludedGuildIds: filter.excluded_guild_ids.map((id) => id.toString()),
includedGuildIds: filter.included_guild_ids.map((id) => id.toString()),
startTimestamp: filter.start_date ? new Date(filter.start_date).getTime() : null,
endTimestamp: filter.end_date ? new Date(filter.end_date).getTime() : null,
},
filter: serializeSelfMessageFilter(filter),
}
: {}),
});
@@ -508,7 +490,7 @@ export class UserContentService {
};
}
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<HarvestStatusResponse> {
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
if (!harvest) {
@@ -517,7 +499,7 @@ export class UserContentService {
return harvest.toResponse();
}
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
async getLatestHarvest(userId: UserID): Promise<HarvestStatusResponse | null> {
const harvestRepository = new UserHarvestRepository();
const harvest = await harvestRepository.findLatestByUserId(userId);
return harvest ? harvest.toResponse() : null;
@@ -650,18 +632,7 @@ export class UserContentService {
'bulkDeleteSelfMessagesImmediate',
{
userId: userId.toString(),
filter: {
scope: filter.scope,
includeDms: filter.include_dms,
includeDmsClosed: filter.include_dms_closed,
includeGroupDms: filter.include_group_dms,
includeGuilds: filter.include_guilds,
guildFilterMode: filter.guild_filter_mode,
excludedGuildIds: filter.excluded_guild_ids.map((id) => id.toString()),
includedGuildIds: filter.included_guild_ids.map((id) => id.toString()),
startTimestamp: filter.start_date ? new Date(filter.start_date).getTime() : null,
endTimestamp: filter.end_date ? new Date(filter.end_date).getTime() : null,
},
filter: serializeSelfMessageFilter(filter),
},
{maxAttempts: 5},
);
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
export function chunkArray<T>(items: ReadonlyArray<T>, chunkSize: number): Array<Array<T>> {
assert(Number.isSafeInteger(chunkSize) && chunkSize > 0, 'Chunk size must be a positive safe integer');
const chunks: Array<Array<T>> = [];
for (let index = 0; index < items.length; index += chunkSize) {
chunks.push(items.slice(index, index + chunkSize));
}
return chunks;
}
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
export async function mapWithConcurrency<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<Array<TResult>> {
assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Concurrency must be a positive safe integer');
const results = new Array<TResult>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
+2 -1
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import type {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
export type Currency = PremiumCurrency;
export function getCurrency(countryCode: string | null | undefined): Currency {
return getCurrencyPreferences(countryCode)[0];
+18 -9
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import type {HttpClient, RequestOptions, RequestUrlPolicy, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
import type {
HttpClient,
RequestOptions,
RequestUrlPolicy,
ResponseStream,
StreamResponse,
} from '@pkgs/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
@@ -40,7 +46,7 @@ function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
return scopedClient;
}
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions): Promise<StreamResponse> {
const requestClient = getHttpClientForRequest(options);
return requestClient.sendRequest(opts);
}
@@ -56,7 +62,7 @@ export class ResponseBodyTooLargeError extends Error {
}
}
interface StreamToStringWithLimitOptions {
interface ResponseBodyReadOptions {
maxBytes: number;
headers?: Headers;
url?: string;
@@ -77,7 +83,7 @@ function parseContentLength(value: string | null | undefined): number | null {
}
function createResponseBodyTooLargeError(
options: StreamToStringWithLimitOptions,
options: ResponseBodyReadOptions,
actualBytes: number | null,
): ResponseBodyTooLargeError {
const description = options.description ?? 'Response body';
@@ -91,8 +97,8 @@ function createResponseBodyTooLargeError(
}
export async function streamToBufferWithLimit(
stream: StreamResponse['stream'],
options: StreamToStringWithLimitOptions,
stream: ResponseStream,
options: ResponseBodyReadOptions,
): Promise<Uint8Array> {
if (!stream) {
return new Uint8Array(0);
@@ -124,6 +130,9 @@ export async function streamToBufferWithLimit(
throw abortError;
}
const {done, value} = await reader.read();
if (options.signal?.aborted) {
throw abortError;
}
if (done) {
break;
}
@@ -133,7 +142,7 @@ export async function streamToBufferWithLimit(
totalSize += value.byteLength;
if (totalSize > options.maxBytes) {
const error = createResponseBodyTooLargeError(options, totalSize);
await reader.cancel(error).catch(() => {});
void reader.cancel(error).catch(() => {});
throw error;
}
chunks.push(value);
@@ -155,8 +164,8 @@ export async function streamToBufferWithLimit(
}
export async function streamToStringWithLimit(
stream: StreamResponse['stream'],
options: StreamToStringWithLimitOptions,
stream: ResponseStream,
options: ResponseBodyReadOptions,
): Promise<string> {
const merged = await streamToBufferWithLimit(stream, options);
return new TextDecoder().decode(merged);
@@ -0,0 +1,37 @@
import {describe, expect, it} from 'vitest';
import {chunkArray} from '../ArrayUtils';
describe('chunkArray', () => {
it.each([
{size: 1, expected: [[1], [2], [3], [4], [5]]},
{size: 2, expected: [[1, 2], [3, 4], [5]]},
{size: 5, expected: [[1, 2, 3, 4, 5]]},
{size: 10, expected: [[1, 2, 3, 4, 5]]},
])('partitions input into chunks of at most $size', ({size, expected}) => {
expect(chunkArray([1, 2, 3, 4, 5], size)).toEqual(expected);
});
it('preserves the input and returns independent chunk arrays', () => {
const items = Object.freeze([1, 2, 3]);
const chunks = chunkArray(items, 2);
chunks[0]!.push(4);
expect(items).toEqual([1, 2, 3]);
expect(chunks).toEqual([[1, 2, 4], [3]]);
});
it('returns no chunks for empty input', () => {
expect(chunkArray([], 2)).toEqual([]);
});
it.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 1,
])('rejects invalid chunk size %j even for empty input', (size) => {
expect(() => chunkArray([1], size)).toThrow('Chunk size must be a positive safe integer');
expect(() => chunkArray([], size)).toThrow('Chunk size must be a positive safe integer');
});
});
@@ -0,0 +1,117 @@
import {describe, expect, it} from 'vitest';
import {mapWithConcurrency} from '../ConcurrencyUtils';
describe('mapWithConcurrency', () => {
it('limits in-flight work and preserves input order when later items finish first', async () => {
const items = ['a', 'b', 'c', 'd'];
const gates = items.map(() => Promise.withResolvers<number>());
const thirdStarted = Promise.withResolvers<void>();
const fourthStarted = Promise.withResolvers<void>();
const started: Array<[string, number]> = [];
let active = 0;
let peakActive = 0;
const result = mapWithConcurrency(items, 2, async (item, index) => {
started.push([item, index]);
active++;
peakActive = Math.max(peakActive, active);
if (index === 2) thirdStarted.resolve();
if (index === 3) fourthStarted.resolve();
try {
return await gates[index]!.promise;
} finally {
active--;
}
});
expect(started).toEqual([
['a', 0],
['b', 1],
]);
gates[1]!.resolve(20);
await thirdStarted.promise;
expect(started).toEqual([
['a', 0],
['b', 1],
['c', 2],
]);
expect(active).toBe(2);
gates[2]!.resolve(30);
await fourthStarted.promise;
expect(started).toEqual([
['a', 0],
['b', 1],
['c', 2],
['d', 3],
]);
gates[3]!.resolve(40);
gates[0]!.resolve(10);
await expect(result).resolves.toEqual([10, 20, 30, 40]);
expect(peakActive).toBe(2);
expect(active).toBe(0);
});
it.each([1, 2, 10])('maps every item exactly once with concurrency %i', async (concurrency) => {
const indexes: Array<number> = [];
const result = await mapWithConcurrency([10, 20, 30], concurrency, async (item, index) => {
indexes.push(index);
return item + index;
});
expect(indexes).toEqual([0, 1, 2]);
expect(result).toEqual([10, 21, 32]);
});
it('returns an empty array without invoking the mapper', async () => {
await expect(
mapWithConcurrency([], 2, () => {
throw new Error('An empty input must not invoke the mapper');
}),
).resolves.toEqual([]);
});
it.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 1,
])('rejects invalid concurrency %j before scheduling work', async (concurrency) => {
let calls = 0;
const mapper = async () => {
calls++;
return 1;
};
await expect(mapWithConcurrency([1], concurrency, mapper)).rejects.toThrow(
'Concurrency must be a positive safe integer',
);
await expect(mapWithConcurrency([], concurrency, mapper)).rejects.toThrow(
'Concurrency must be a positive safe integer',
);
expect(calls).toBe(0);
});
it('propagates a mapper rejection without waiting for another in-flight item', async () => {
const first = Promise.withResolvers<number>();
const second = Promise.withResolvers<number>();
const failure = new Error('Mapper failed');
const result = mapWithConcurrency([first.promise, second.promise], 2, (promise) => promise);
const rejected = expect(result).rejects.toBe(failure);
first.reject(failure);
await rejected;
second.resolve(2);
await second.promise;
});
it('rejects synchronous mapper failures without starting queued serial work', async () => {
const failure = new Error('Mapper failed synchronously');
const started: Array<number> = [];
await expect(
mapWithConcurrency([1, 2], 1, (item) => {
started.push(item);
throw failure;
}),
).rejects.toBe(failure);
expect(started).toEqual([1]);
});
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {describe, expect, it, vi} from 'vitest';
import * as FetchUtils from '../FetchUtils';
function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
@@ -18,31 +18,76 @@ function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
});
}
describe('FetchUtils', () => {
describe('streamToBufferWithLimit', () => {
it('returns the full response body when it is within the limit', async () => {
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 32,
describe('streamToBufferWithLimit', () => {
it('returns the full response body when it is within the limit', async () => {
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 32,
description: 'Test response',
});
expect(new TextDecoder().decode(body)).toBe('hello world');
});
it('rejects when the declared content-length exceeds the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
maxBytes: 4,
headers: new Headers({'Content-Length': '5'}),
description: 'Test response',
});
expect(new TextDecoder().decode(body)).toBe('hello world');
});
it('rejects when the declared content-length exceeds the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
maxBytes: 4,
headers: new Headers({'Content-Length': '5'}),
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects when the streamed body grows beyond the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 5,
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects when the streamed body grows beyond the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 5,
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects an abort during a pending read instead of returning a partial response', async () => {
const pendingRead = Promise.withResolvers<void>();
const abort = new AbortController();
const stream = new ReadableStream<Uint8Array>(
{
start(controller) {
controller.enqueue(new TextEncoder().encode('partial'));
},
pull() {
pendingRead.resolve();
},
},
{highWaterMark: 0},
);
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 32, signal: abort.signal});
await pendingRead.promise;
abort.abort();
await expect(result).rejects.toMatchObject({name: 'AbortError'});
expect(stream.locked).toBe(false);
});
it('rejects an oversized response without waiting for cancellation to finish', async () => {
vi.useFakeTimers();
const cancelled = Promise.withResolvers<void>();
const cleanup = Promise.withResolvers<void>();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(5));
},
cancel() {
cancelled.resolve();
return cleanup.promise;
},
});
const settled = vi.fn();
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 4}).then(settled, settled);
try {
await cancelled.promise;
await vi.advanceTimersByTimeAsync(0);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(FetchUtils.ResponseBodyTooLargeError));
expect(stream.locked).toBe(false);
} finally {
cleanup.resolve();
await result;
vi.useRealTimers();
}
});
});
+32 -70
View File
@@ -4,81 +4,43 @@ import {describe, expect, it} from 'vitest';
import {toIdString, toSortedIdArray} from '../IdUtils';
describe('toIdString', () => {
it('returns null for null input', () => {
expect(toIdString(null)).toBeNull();
});
it('returns null for undefined input', () => {
expect(toIdString(undefined)).toBeNull();
});
it('converts bigint to string', () => {
expect(toIdString(123456789012345678n)).toBe('123456789012345678');
});
it('converts string to string (passthrough)', () => {
expect(toIdString('123456789012345678')).toBe('123456789012345678');
});
it('handles zero bigint', () => {
expect(toIdString(0n)).toBe('0');
});
it('handles very large bigints', () => {
const large = 999999999999999999999999999999n;
expect(toIdString(large)).toBe(large.toString());
});
it('handles negative bigints', () => {
expect(toIdString(-1n)).toBe('-1');
});
it('preserves numeric string exactly', () => {
expect(toIdString('0')).toBe('0');
expect(toIdString('00123')).toBe('00123');
it.each([
[null, null],
[undefined, null],
[123456789012345678n, '123456789012345678'],
['123456789012345678', '123456789012345678'],
[0n, '0'],
[999999999999999999999999999999n, '999999999999999999999999999999'],
[-1n, '-1'],
['0', '0'],
['00123', '00123'],
])('preserves the exact ID representation of %s', (input, expected) => {
expect(toIdString(input)).toBe(expected);
});
});
describe('toSortedIdArray', () => {
it('returns empty array for null input', () => {
expect(toSortedIdArray(null)).toEqual([]);
it.each([
{input: null, expected: []},
{input: undefined, expected: []},
{input: [], expected: []},
{input: new Set<bigint>(), expected: []},
{input: [300n, 100n, 200n], expected: ['100', '200', '300']},
{input: ['300', '100', '200'], expected: ['100', '200', '300']},
{input: new Set([300n, 100n, 200n]), expected: ['100', '200', '300']},
{input: [42n], expected: ['42']},
{input: new Set([123n]), expected: ['123']},
{input: [100n, 200n, 300n], expected: ['100', '200', '300']},
{input: [300n, 200n, 100n], expected: ['100', '200', '300']},
{input: [2n, 10n, 1n], expected: ['1', '10', '2']},
{input: [100n, 100n, 200n], expected: ['100', '100', '200']},
])('stringifies and lexicographically sorts case %#', ({input, expected}) => {
expect(toSortedIdArray<bigint | string>(input)).toEqual(expected);
});
it('returns empty array for undefined input', () => {
expect(toSortedIdArray(undefined)).toEqual([]);
});
it('returns empty array for empty array input', () => {
expect(toSortedIdArray([])).toEqual([]);
});
it('converts and sorts array of bigints', () => {
it('does not reorder the caller-owned array', () => {
const input = [300n, 100n, 200n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('converts and sorts array of strings', () => {
const input = ['300', '100', '200'];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('converts Set to sorted array', () => {
const input = new Set([300n, 100n, 200n]);
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('handles single element array', () => {
expect(toSortedIdArray([42n])).toEqual(['42']);
});
it('handles already sorted input', () => {
const input = [100n, 200n, 300n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('handles reverse sorted input', () => {
const input = [300n, 200n, 100n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('sorts lexicographically (string sort)', () => {
const input = [2n, 10n, 1n];
expect(toSortedIdArray(input)).toEqual(['1', '10', '2']);
});
it('handles duplicates in input array', () => {
const input = [100n, 100n, 200n];
expect(toSortedIdArray(input)).toEqual(['100', '100', '200']);
});
it('handles Set with single element', () => {
const input = new Set([123n]);
expect(toSortedIdArray(input)).toEqual(['123']);
});
it('handles empty Set', () => {
const input = new Set<bigint>();
expect(toSortedIdArray(input)).toEqual([]);
toSortedIdArray(input);
expect(input).toEqual([300n, 100n, 200n]);
});
});
@@ -4,79 +4,40 @@ import {describe, expect, it} from 'vitest';
import {hasVisibleContent, parseString} from '../StringUtils';
describe('parseString', () => {
it('returns trimmed string within max length', () => {
expect(parseString('Hello World', 50)).toBe('Hello World');
});
it('trims whitespace from the string', () => {
expect(parseString(' Hello World ', 50)).toBe('Hello World');
});
it('truncates string exceeding max length', () => {
const result = parseString('This is a very long string that needs truncation', 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toContain('...');
});
it('handles exact max length', () => {
const result = parseString('12345', 5);
expect(result).toBe('12345');
});
it('decodes HTML entities before processing', () => {
expect(parseString('Hello &amp; World', 50)).toBe('Hello & World');
expect(parseString('&lt;script&gt;', 50)).toBe('<script>');
});
it('handles empty string', () => {
expect(parseString('', 50)).toBe('');
});
it('handles string with only whitespace', () => {
expect(parseString(' ', 50)).toBe('');
});
it('handles unicode characters', () => {
expect(parseString('Hello \u{1F600} World', 50)).toBe('Hello \u{1F600} World');
});
it('truncates with ellipsis by default', () => {
const result = parseString('This is a test string for truncation', 15);
expect(result.endsWith('...')).toBe(true);
});
it('handles max length of 0 (lodash truncate returns ellipsis)', () => {
const result = parseString('Hello', 0);
expect(result).toBe('...');
});
it('handles max length of 1 (lodash truncate minimum is ellipsis length)', () => {
const result = parseString('Hello', 1);
expect(result).toBe('...');
});
it('handles max length of 3 (minimum for truncation)', () => {
const result = parseString('Hello', 3);
expect(result).toBe('...');
});
it('handles max length of 4', () => {
const result = parseString('Hello', 4);
expect(result.length).toBeLessThanOrEqual(4);
});
it('preserves string with special characters', () => {
const input = 'Hello\nWorld\tTab';
const result = parseString(input, 50);
expect(result).toContain('\n');
expect(result).toContain('\t');
});
it('handles combined HTML entities and long strings', () => {
const input = '&amp; '.repeat(20);
const result = parseString(input, 30);
expect(result.length).toBeLessThanOrEqual(30);
it.each([
{input: 'Hello World', max: 50, expected: 'Hello World'},
{input: ' Hello World ', max: 50, expected: 'Hello World'},
{input: 'This is a very long string that needs truncation', max: 20, expected: 'This is a very lo...'},
{input: '12345', max: 5, expected: '12345'},
{input: 'Hello &amp; World', max: 50, expected: 'Hello & World'},
{input: '&lt;script&gt;', max: 50, expected: '<script>'},
{input: '', max: 50, expected: ''},
{input: ' ', max: 50, expected: ''},
{input: 'Hello 😀 World', max: 50, expected: 'Hello 😀 World'},
{input: 'This is a test string for truncation', max: 15, expected: 'This is a te...'},
{input: 'Hello', max: 0, expected: '...'},
{input: 'Hello', max: 1, expected: '...'},
{input: 'Hello', max: 3, expected: '...'},
{input: 'Hello', max: 4, expected: 'H...'},
{input: 'Hello\nWorld\tTab', max: 50, expected: 'Hello\nWorld\tTab'},
{input: '&amp;&amp;&amp;abc', max: 5, expected: '&&...'},
{input: '😀abcd', max: 4, expected: '😀...'},
])('decodes, trims, and truncates $input to $expected', ({input, max, expected}) => {
expect(parseString(input, max)).toBe(expected);
});
});
describe('hasVisibleContent', () => {
it('rejects whitespace and invisible-only strings', () => {
expect(hasVisibleContent('')).toBe(false);
expect(hasVisibleContent(' \t\n')).toBe(false);
expect(hasVisibleContent('\u200e \u200b\ufeff')).toBe(false);
expect(hasVisibleContent('\u2800\u3164\u{e0100}')).toBe(false);
it.each([
'',
' \t\n',
'\u200e \u200b\ufeff',
'\u2800\u3164\u{e0100}',
])('rejects whitespace or invisible-only input %j', (input) => {
expect(hasVisibleContent(input)).toBe(false);
});
it('allows any visible character', () => {
expect(hasVisibleContent('hello')).toBe(true);
expect(hasVisibleContent('\u200e hello')).toBe(true);
expect(hasVisibleContent('🙂')).toBe(true);
expect(hasVisibleContent('` `')).toBe(true);
it.each(['hello', '\u200e hello', '🙂', '` `'])('accepts visible input %j', (input) => {
expect(hasVisibleContent(input)).toBe(true);
});
});
@@ -25,9 +25,14 @@ import {
WebhookTokenUpdateRequest,
WebhookUpdateRequest,
} from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas';
import {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import {
SlackWebhookResponse,
WebhookListResponse,
WebhookResponse,
WebhookTokenResponse,
} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
import {
createChannelID,
createGuildID,
@@ -128,7 +133,7 @@ export function WebhookController(app: HonoApp) {
summary: 'List guild webhooks',
description:
'Returns a list of all webhooks configured in the specified guild. Requires the user to have appropriate permissions to view webhooks in the guild.',
responseSchema: z.array(WebhookResponse),
responseSchema: WebhookListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
@@ -152,7 +157,7 @@ export function WebhookController(app: HonoApp) {
summary: 'List channel webhooks',
description:
'Returns a list of all webhooks configured in the specified channel. Requires the user to have appropriate permissions to view webhooks in the channel.',
responseSchema: z.array(WebhookResponse),
responseSchema: WebhookListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
@@ -492,7 +497,7 @@ export function WebhookController(app: HonoApp) {
summary: 'Execute Slack webhook',
description:
'Receives and processes Slack-formatted webhook payloads, converting them to messages in the configured channel. Returns "ok" as plain text with a 200 status code.',
responseSchema: z.string(),
responseSchema: SlackWebhookResponse,
statusCode: 200,
tags: ['Webhooks'],
}),
+3 -4
View File
@@ -2,14 +2,13 @@
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
import type {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import type {z} from 'zod';
import {createUserID} from '../BrandedTypes';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Webhook} from '../models/Webhook';
import {getCachedUserPartialResponse} from '../user/UserCacheHelpers';
export function mapWebhookToTokenResponse(webhook: Webhook): z.infer<typeof WebhookTokenResponse> {
export function mapWebhookToTokenResponse(webhook: Webhook): WebhookTokenResponse {
return {
id: webhook.id.toString(),
guild_id: webhook.guildId?.toString() || '',
@@ -28,7 +27,7 @@ export async function mapWebhookToResponseWithCache({
webhook: Webhook;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<z.infer<typeof WebhookResponse>> {
}): Promise<WebhookResponse> {
const creatorPartial = await getCachedUserPartialResponse({
userId: webhook.creatorId ?? createUserID(DELETED_USER_ID),
userCacheService,
@@ -48,7 +47,7 @@ export async function mapWebhooksToResponse({
webhooks: Array<Webhook>;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Array<z.infer<typeof WebhookResponse>>> {
}): Promise<Array<WebhookResponse>> {
return await Promise.all(
webhooks.map((webhook) => mapWebhookToResponseWithCache({webhook, userCacheService, requestCache})),
);
+2 -2
View File
@@ -77,7 +77,7 @@ export class WorkerRunner {
private readonly ledger: IJobLedgerRepository;
private readonly heartbeat: WorkerHeartbeat | null;
private heartbeatSignal: WorkerHeartbeatSignal | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private running = false;
private consumerMessages: ConsumerMessages | null = null;
private processingLoop: Promise<void> | null = null;
@@ -447,7 +447,7 @@ export class WorkerRunner {
msg.term(RETIRED_TASK_REASON);
}
private startAckHeartbeat(taskType: string, msg: JsMsg): ReturnType<typeof setInterval> {
private startAckHeartbeat(taskType: string, msg: JsMsg): NodeJS.Timeout {
const heartbeat = setInterval(
() => {
try {
@@ -13,8 +13,8 @@ import {mapGuildMemberToResponse} from '../../guild/GuildModel';
import {Logger} from '../../Logger';
import {createRequestCache} from '../../middleware/RequestCacheMiddleware';
import {remapAuthorMessagesToDeletedUser} from '../../oauth/ApplicationMessageAuthorAnonymization';
import {chunkArray} from '../../utils/ArrayUtils';
import {getWorkerDependencies} from '../WorkerContext';
import {chunkArray} from './utils/MessageDeletion';
const PayloadSchema = z.object({
applicationId: z.string(),

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