Files
pokopiawiki.tootaio.com/backend/src/server.ts
xiaomai 2220d5d595 feat(dish): add dish management and public view
Add database schema, permissions, and API endpoints for dishes
Implement frontend views and admin management for dish data
2026-05-04 21:00:23 +08:00

2224 lines
74 KiB
TypeScript

import cors from '@fastify/cors';
import multipart, { type MultipartFile } from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import Fastify from 'fastify';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { createHash } from 'node:crypto';
import { mkdir } from 'node:fs/promises';
import {
changeCurrentUserPassword,
createPermission,
createRole,
deletePermission,
deleteRole,
getReferralSummary,
getUserBySessionToken,
listAdminUsers,
listPermissions,
listRoles,
loginUser,
logoutSession,
registerUser,
requestPasswordReset,
resetPassword,
updateAdminUserRoles,
updateCurrentUser,
updatePermission,
updateRole,
updateRolePermissions,
userHasAnyPermission,
userHasPermission,
verifyEmail,
type AuthUser
} from './auth.ts';
import { initializeDatabase, pool } from './db.ts';
import {
cleanLocale,
createAncientArtifact,
createConfig,
createDailyChecklistItem,
createDish,
createDishCategory,
createEntityDiscussionComment,
createEntityDiscussionReply,
createHabitat,
createItem,
createLanguage,
createLifeComment,
createLifeCommentReply,
createLifePost,
createPokemon,
createRecipe,
deleteConfig,
deleteAncientArtifact,
deleteDailyChecklistItem,
deleteDish,
deleteDishCategory,
deleteEntityDiscussionComment,
deleteHabitat,
deleteItem,
deleteLanguage,
deleteEntityDiscussionCommentLike,
deleteLifeComment,
deleteLifeCommentLike,
deleteLifePost,
deleteLifePostRating,
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
exportAdminData,
fetchPokemonData,
fetchPokemonImageOptions,
followUser,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
getItem,
listDish,
getLifePost,
getOptions,
getPokemon,
getPublicUserProfile,
getRecipe,
globalSearch,
importAdminData,
isConfigType,
listAncientArtifacts,
listEntityDiscussionComments,
listConfig,
listDailyChecklistItems,
listHabitats,
listFollowingLifePosts,
listItems,
listLifeComments,
listLanguages,
listLifePosts,
listLifePostReactionUsers,
listPokemon,
listPokemonFetchOptions,
listRecipes,
listUserCommentActivities,
listUserLifePosts,
listUserReactionActivities,
reorderConfig,
reorderAncientArtifacts,
reorderDailyChecklistItems,
reorderDishCategories,
reorderDishes,
reorderHabitats,
reorderItems,
reorderLanguages,
reorderPokemon,
reorderRecipes,
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
retryLifePostModeration,
restoreLifeComment,
setLifePostRating,
setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
updateDish,
updateDishCategory,
updateHabitat,
updateItem,
updateLanguage,
updateLifePost,
updatePokemon,
updateRecipe,
unfollowUser,
wipeAdminData
} from './queries.ts';
import {
getAiModerationSettings,
startAiModerationWorker,
updateAiModerationSettings
} from './aiModeration.ts';
import {
getSystemWordings,
listSystemWordingRows,
localizedStatusMessage,
syncSystemWordingCatalog,
systemMessage,
updateSystemWordingValue
} from './systemWordingQueries.ts';
import {
imageUploadMaxBytes,
isUploadEntityType,
saveEntityImageUpload,
uploadRoot
} from './uploads.ts';
import {
createNotificationWebSocketTicket,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
setupNotificationWebSocketServer
} from './notifications.ts';
const app = Fastify({
logger: true,
trustProxy: process.env.TRUST_PROXY === 'true'
});
function configuredCorsOrigin(): true | string | string[] {
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
if (!rawOrigin) {
return true;
}
const origins = rawOrigin
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
return origins.length <= 1 ? (origins[0] ?? true) : origins;
}
await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin()
});
await app.register(rateLimit, {
global: false,
hook: 'preHandler'
});
await mkdir(uploadRoot, { recursive: true });
await app.register(multipart, {
limits: {
fileSize: imageUploadMaxBytes,
files: 1
}
});
await app.register(fastifyStatic, {
root: uploadRoot,
prefix: '/uploads/',
decorateReply: false
});
app.setErrorHandler(async (error, _request, reply) => {
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
const locale = requestLocale(_request);
if (pgError.code === '23503') {
return reply.code(409).send({ message: await serverMessage(locale, 'foreignKey') });
}
if (pgError.code === '23505') {
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
}
if (pgError.code === '23502' || pgError.code === '23514') {
return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') });
}
if (pgError.statusCode === 429) {
return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') });
}
if (pgError.statusCode === 503) {
return reply.code(503).send({ message: await localizedStatusMessage(locale, pgError.message) });
}
if (pgError.statusCode && pgError.statusCode < 500) {
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
}
app.log.error(error);
return reply.code(500).send({ message: await serverMessage(locale, 'serverError') });
});
app.get('/health', async () => ({ ok: true }));
app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
}
function requestLocale(request: FastifyRequest): string {
const query = request.query as Record<string, string | string[] | undefined>;
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
const headerLocale = request.headers['x-locale'];
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
}
type ProjectUpdatesRepository = {
name: string;
fullName: string;
url: string;
defaultBranch: string;
updatedAt: string | null;
};
type ProjectUpdateCommit = {
sha: string;
shortSha: string;
title: string;
message: string;
createdAt: string;
authorName: string;
url: string;
};
type ProjectUpdateRelease = {
tagName: string;
name: string;
publishedAt: string | null;
url: string;
};
type ProjectCommitPage = {
items: ProjectUpdateCommit[];
nextCursor: string | null;
hasMore: boolean;
};
type ProjectUpdatesCursor = {
page: number;
limit: number;
};
type ProjectUpdatesResponse = {
repository: ProjectUpdatesRepository;
commits: ProjectCommitPage;
releases: ProjectUpdateRelease[];
};
const projectUpdatesConfig = {
apiBaseUrl: 'https://git.tootaio.com/api/v1',
publicBaseUrl: 'https://git.tootaio.com',
owner: 'Kingsmai',
repo: 'pokopiawiki.tootaio.com',
commitLimit: 5,
maxCommitLimit: 20,
releaseLimit: 3,
timeoutMs: 5000
} as const;
function projectRepositoryPath(): string {
return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`;
}
function projectRepositoryUrl(): string {
return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`;
}
function projectApiUrl(path = '', params: Record<string, string | number | boolean> = {}): string {
const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, '');
const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function objectField(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
if (!record) return null;
const value = record[key];
return isObjectRecord(value) ? value : null;
}
function stringField(record: Record<string, unknown> | null, key: string): string | null {
if (!record) return null;
const value = record[key];
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
function normalizedDate(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function projectCommitTitle(message: string | null, fallback: string): string {
const [firstLine] = (message ?? '').split('\n');
return firstLine?.trim() || fallback;
}
function projectUpdatesQueryValue(value: string | string[] | undefined): string | null {
const rawValue = Array.isArray(value) ? value[0] : value;
return rawValue?.trim() || null;
}
function cleanProjectUpdatesLimit(query: Record<string, string | string[] | undefined>): number {
const rawLimit = Number(projectUpdatesQueryValue(query.limit));
if (!Number.isInteger(rawLimit)) {
return projectUpdatesConfig.commitLimit;
}
return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit);
}
function encodeProjectUpdatesCursor(page: number, limit: number): string {
return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url');
}
function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null {
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (!isObjectRecord(payload)) {
return null;
}
const { page, limit } = payload;
if (
typeof page === 'number' &&
Number.isInteger(page) &&
page > 0 &&
typeof limit === 'number' &&
Number.isInteger(limit) &&
limit > 0 &&
limit <= projectUpdatesConfig.maxCommitLimit
) {
return { page, limit };
}
return null;
} catch {
return null;
}
}
function fallbackProjectRepository(): ProjectUpdatesRepository {
return {
name: projectUpdatesConfig.repo,
fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
url: projectRepositoryUrl(),
defaultBranch: 'main',
updatedAt: null
};
}
async function fetchProjectJson(path = '', params: Record<string, string | number | boolean> = {}): Promise<unknown> {
const response = await fetch(projectApiUrl(path, params), {
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs)
});
if (!response.ok) {
throw new Error(`Project updates source failed (${response.status})`);
}
return response.json() as Promise<unknown>;
}
function mapProjectRepository(value: unknown): ProjectUpdatesRepository {
if (!isObjectRecord(value)) {
return fallbackProjectRepository();
}
return {
name: stringField(value, 'name') ?? projectUpdatesConfig.repo,
fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
url: projectRepositoryUrl(),
defaultBranch: stringField(value, 'default_branch') ?? 'main',
updatedAt: normalizedDate(stringField(value, 'updated_at'))
};
}
function mapProjectCommit(value: unknown): ProjectUpdateCommit | null {
if (!isObjectRecord(value)) return null;
const sha = stringField(value, 'sha');
if (!sha) return null;
const commit = objectField(value, 'commit');
const commitAuthor = objectField(commit, 'author');
const message = stringField(commit, 'message') ?? sha.slice(0, 7);
const fallback = sha.slice(0, 7);
const createdAt =
normalizedDate(stringField(value, 'created')) ??
normalizedDate(stringField(commitAuthor, 'date')) ??
normalizedDate(stringField(objectField(commit, 'committer'), 'date'));
if (!createdAt) return null;
return {
sha,
shortSha: sha.slice(0, 7),
title: projectCommitTitle(message, fallback),
message,
createdAt,
authorName:
stringField(commitAuthor, 'name') ??
stringField(objectField(value, 'author'), 'login') ??
projectUpdatesConfig.owner,
url: `${projectRepositoryUrl()}/commit/${sha}`
};
}
function mapProjectRelease(value: unknown): ProjectUpdateRelease | null {
if (!isObjectRecord(value)) return null;
const tagName = stringField(value, 'tag_name');
if (!tagName) return null;
return {
tagName,
name: stringField(value, 'name') ?? tagName,
publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')),
url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}`
};
}
function logProjectUpdatesError(source: string, error: unknown): void {
app.log.warn({ err: error, source }, 'Project updates source unavailable');
}
async function getProjectCommitPage(query: Record<string, string | string[] | undefined>): Promise<ProjectCommitPage> {
const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor));
const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query);
const page = cursor?.page ?? 1;
const value = await fetchProjectJson('/commits', {
page,
limit: limit + 1,
stat: false,
files: false,
verification: false
});
const commits = Array.isArray(value)
? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null)
: [];
const hasMore = commits.length > limit;
return {
items: commits.slice(0, limit),
nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null,
hasMore
};
}
async function getProjectUpdates(query: Record<string, string | string[] | undefined> = {}): Promise<ProjectUpdatesResponse> {
const [repository, commits, releases] = await Promise.all([
fetchProjectJson()
.then(mapProjectRepository)
.catch((error: unknown) => {
logProjectUpdatesError('repository', error);
return fallbackProjectRepository();
}),
getProjectCommitPage(query).catch((error: unknown) => {
logProjectUpdatesError('commits', error);
throw error;
}),
fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false })
.then((value) =>
Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : []
)
.catch((error: unknown) => {
logProjectUpdatesError('releases', error);
return [];
})
]);
return { repository, commits, releases };
}
function serverMessage(
locale: string,
key:
| 'foreignKey'
| 'duplicate'
| 'invalidField'
| 'serverError'
| 'loginRequired'
| 'verifyEmailFirst'
| 'permissionDenied'
| 'notFound'
| 'rateLimited'
): Promise<string> {
return systemMessage(locale, `server.errors.${key}`);
}
type RateLimitCheck = ReturnType<typeof app.createRateLimit>;
type RateLimitPolicy = 'accountWrite' | 'adminWrite' | 'communityReaction' | 'communityWrite' | 'fetch' | 'upload' | 'wikiWrite';
type RateLimitPolicySettings = {
maxRequests: number;
timeWindowSeconds: number;
cooldownSeconds: number;
};
type RateLimitPolicySettingsMap = Record<RateLimitPolicy, RateLimitPolicySettings>;
type RateLimitFailure = {
max: number;
ttlInSeconds: number;
isBanned?: boolean;
};
type RateLimitSettingsRow = {
settings: unknown;
updatedAt: Date | string;
updatedBy: { id: number; displayName: string } | null;
};
type PublicRateLimitSettings = {
policies: RateLimitPolicySettingsMap;
updatedAt: Date | string | null;
updatedBy: { id: number; displayName: string } | null;
};
function hashRateLimitPart(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 32);
}
function routeRateLimitPart(request: FastifyRequest): string {
return `${request.method}:${request.routeOptions.url ?? request.url.split('?')[0]}`;
}
function emailRateLimitPart(request: FastifyRequest): string {
const body = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
return hashRateLimitPart(email || 'missing-email');
}
function ipRouteRateLimitKey(scope: string, request: FastifyRequest): string {
return `${scope}:ip:${hashRateLimitPart(request.ip)}:route:${routeRateLimitPart(request)}`;
}
const authRouteIpRateLimit = app.createRateLimit({
max: 20,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('auth', request)
});
const loginEmailRateLimit = app.createRateLimit({
max: 5,
timeWindow: '15 minutes',
keyGenerator: (request) => `auth:login:email:${emailRateLimitPart(request)}`
});
const registerEmailRateLimit = app.createRateLimit({
max: 3,
timeWindow: '1 hour',
keyGenerator: (request) => `auth:register:email:${emailRateLimitPart(request)}`
});
const passwordResetEmailRateLimit = app.createRateLimit({
max: 3,
timeWindow: '1 hour',
keyGenerator: (request) => `auth:password-reset:email:${emailRateLimitPart(request)}`
});
const passwordResetRouteIpRateLimit = app.createRateLimit({
max: 10,
timeWindow: '15 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('auth:password-reset', request)
});
const protectedRouteIpRateLimit = app.createRateLimit({
max: 120,
timeWindow: '10 minutes',
keyGenerator: (request) => ipRouteRateLimitKey('protected', request)
});
const rateLimitPolicyKeys: RateLimitPolicy[] = [
'accountWrite',
'adminWrite',
'communityReaction',
'communityWrite',
'fetch',
'upload',
'wikiWrite'
];
const defaultUserRateLimitSettings: RateLimitPolicySettingsMap = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const rateLimitSettingsCacheTtlMs = 30_000;
const minRateLimitWindowSeconds = 60;
const maxRateLimitWindowSeconds = 24 * 60 * 60;
const maxRateLimitRequests = 5_000;
const maxRateLimitCooldownSeconds = 60 * 60;
let rateLimitSettingsCache: { settings: RateLimitPolicySettingsMap; expiresAt: number } | null = null;
let lastRateLimitSweepAt = 0;
const userRateLimitWindows = new Map<string, { count: number; resetAt: number }>();
const userRateLimitCooldowns = new Map<string, number>();
function cloneRateLimitSettings(settings: RateLimitPolicySettingsMap): RateLimitPolicySettingsMap {
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [policy, { ...settings[policy] }])
) as RateLimitPolicySettingsMap;
}
function cleanRateLimitInteger(value: unknown, fallback: number, min: number, max: number): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
return fallback;
}
return numeric;
}
function cleanRateLimitPolicySettings(value: unknown, fallback: RateLimitPolicySettings): RateLimitPolicySettings {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
maxRequests: cleanRateLimitInteger(raw.maxRequests, fallback.maxRequests, 1, maxRateLimitRequests),
timeWindowSeconds: cleanRateLimitInteger(
raw.timeWindowSeconds,
fallback.timeWindowSeconds,
minRateLimitWindowSeconds,
maxRateLimitWindowSeconds
),
cooldownSeconds: cleanRateLimitInteger(raw.cooldownSeconds, fallback.cooldownSeconds, 0, maxRateLimitCooldownSeconds)
};
}
function normalizeRateLimitSettings(value: unknown): RateLimitPolicySettingsMap {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
cleanRateLimitPolicySettings(raw[policy], defaultUserRateLimitSettings[policy])
])
) as RateLimitPolicySettingsMap;
}
function publicRateLimitSettings(row: RateLimitSettingsRow | null, settings: RateLimitPolicySettingsMap): PublicRateLimitSettings {
return {
policies: cloneRateLimitSettings(settings),
updatedAt: row?.updatedAt ?? null,
updatedBy: row?.updatedBy ?? null
};
}
async function rateLimitSettingsRow(): Promise<RateLimitSettingsRow | null> {
const result = await pool.query<RateLimitSettingsRow>(
`
SELECT
s.settings,
s.updated_at AS "updatedAt",
CASE
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy"
FROM rate_limit_settings s
LEFT JOIN users updated_user ON updated_user.id = s.updated_by_user_id
WHERE s.id = true
`
);
return result.rows[0] ?? null;
}
async function runtimeRateLimitSettings(): Promise<RateLimitPolicySettingsMap> {
const now = Date.now();
if (rateLimitSettingsCache && rateLimitSettingsCache.expiresAt > now) {
return rateLimitSettingsCache.settings;
}
const row = await rateLimitSettingsRow();
const settings = normalizeRateLimitSettings(row?.settings);
rateLimitSettingsCache = { settings, expiresAt: now + rateLimitSettingsCacheTtlMs };
return settings;
}
async function getRateLimitSettings(): Promise<PublicRateLimitSettings> {
const row = await rateLimitSettingsRow();
return publicRateLimitSettings(row, normalizeRateLimitSettings(row?.settings));
}
async function updateRateLimitSettings(payload: Record<string, unknown>, userId: number): Promise<PublicRateLimitSettings> {
const policies = payload.policies && typeof payload.policies === 'object' ? payload.policies : payload;
const settings = normalizeRateLimitSettings(policies);
await pool.query(
`
INSERT INTO rate_limit_settings (id, settings, updated_by_user_id, updated_at)
VALUES (true, $1::jsonb, $2, now())
ON CONFLICT (id)
DO UPDATE SET settings = EXCLUDED.settings,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
`,
[JSON.stringify(settings), userId]
);
rateLimitSettingsCache = { settings, expiresAt: Date.now() + rateLimitSettingsCacheTtlMs };
return getRateLimitSettings();
}
function sweepUserRateLimitEntries(now: number): void {
if (now - lastRateLimitSweepAt < 60_000) {
return;
}
lastRateLimitSweepAt = now;
for (const [key, bucket] of userRateLimitWindows.entries()) {
if (bucket.resetAt <= now) {
userRateLimitWindows.delete(key);
}
}
for (const [key, resetAt] of userRateLimitCooldowns.entries()) {
if (resetAt <= now) {
userRateLimitCooldowns.delete(key);
}
}
}
function checkUserPolicyRateLimit(userId: number, policy: RateLimitPolicy, settings: RateLimitPolicySettings): RateLimitFailure | null {
const now = Date.now();
sweepUserRateLimitEntries(now);
const cooldownKey = `${policy}:user:${userId}:cooldown`;
const cooldownResetAt = userRateLimitCooldowns.get(cooldownKey) ?? 0;
if (cooldownResetAt > now) {
return { max: 1, ttlInSeconds: Math.ceil((cooldownResetAt - now) / 1000) };
}
const windowKey = `${policy}:user:${userId}:window`;
const windowResetMs = settings.timeWindowSeconds * 1000;
const existingBucket = userRateLimitWindows.get(windowKey);
const bucket =
existingBucket && existingBucket.resetAt > now
? existingBucket
: { count: 0, resetAt: now + windowResetMs };
if (bucket.count >= settings.maxRequests) {
userRateLimitWindows.set(windowKey, bucket);
return { max: settings.maxRequests, ttlInSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
bucket.count += 1;
userRateLimitWindows.set(windowKey, bucket);
if (settings.cooldownSeconds > 0) {
userRateLimitCooldowns.set(cooldownKey, now + settings.cooldownSeconds * 1000);
}
return null;
}
async function sendRateLimited(
request: FastifyRequest,
reply: FastifyReply,
result: RateLimitFailure
): Promise<false> {
const retryAfter = Math.max(1, result.ttlInSeconds);
reply.header('retry-after', retryAfter);
reply.header('x-ratelimit-limit', result.max);
reply.header('x-ratelimit-remaining', 0);
reply.header('x-ratelimit-reset', retryAfter);
reply.code(result.isBanned === true ? 403 : 429).send({ message: await serverMessage(requestLocale(request), 'rateLimited') });
return false;
}
async function enforceRateLimits(
request: FastifyRequest,
reply: FastifyReply,
checks: RateLimitCheck[]
): Promise<boolean> {
for (const check of checks) {
const result = await check(request);
if (!result.isAllowed && result.isExceeded) {
return sendRateLimited(request, reply, result);
}
}
return true;
}
async function enforceAuthRateLimits(
request: FastifyRequest,
reply: FastifyReply,
checks: RateLimitCheck[]
): Promise<boolean> {
return enforceRateLimits(request, reply, [authRouteIpRateLimit, ...checks]);
}
async function enforceUserRateLimits(
request: FastifyRequest,
reply: FastifyReply,
user: AuthUser,
policy: RateLimitPolicy
): Promise<boolean> {
const settings = (await runtimeRateLimitSettings())[policy];
const result = checkUserPolicyRateLimit(user.id, policy, settings);
return result ? sendRateLimited(request, reply, result) : true;
}
function badRequest(message: string): Error & { statusCode: number } {
const error = new Error(message) as Error & { statusCode: number };
error.statusCode = 400;
return error;
}
async function notFound(reply: FastifyReply, request: FastifyRequest) {
return reply.code(404).send({ message: await serverMessage(requestLocale(request), 'notFound') });
}
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return null;
}
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request);
if (!user) {
reply.code(401).send({ message: await serverMessage(locale, 'loginRequired') });
return null;
}
if (!user.emailVerified) {
reply.code(403).send({ message: await serverMessage(locale, 'verifyEmailFirst') });
return null;
}
return user;
}
async function requirePermission(
request: FastifyRequest,
reply: FastifyReply,
permissionKey: string
): Promise<AuthUser | null> {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return null;
}
if (!userHasPermission(user, permissionKey)) {
reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') });
return null;
}
return user;
}
async function requireAnyPermission(
request: FastifyRequest,
reply: FastifyReply,
permissionKeys: string[]
): Promise<AuthUser | null> {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return null;
}
if (!userHasAnyPermission(user, permissionKeys)) {
reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') });
return null;
}
return user;
}
async function requirePermissionWithRateLimits(
request: FastifyRequest,
reply: FastifyReply,
permissionKey: string,
policy: RateLimitPolicy
): Promise<AuthUser | null> {
const user = await requirePermission(request, reply, permissionKey);
if (!user || !(await enforceUserRateLimits(request, reply, user, policy))) {
return null;
}
return user;
}
async function requireAnyPermissionWithRateLimits(
request: FastifyRequest,
reply: FastifyReply,
permissionKeys: string[],
policy: RateLimitPolicy
): Promise<AuthUser | null> {
const user = await requireAnyPermission(request, reply, permissionKeys);
if (!user || !(await enforceUserRateLimits(request, reply, user, policy))) {
return null;
}
return user;
}
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization);
if (!token) {
return null;
}
try {
return await getUserBySessionToken(token);
} catch {
return null;
}
}
app.post('/api/auth/register', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, [registerEmailRateLimit]))) {
return;
}
return reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)));
});
app.post('/api/auth/verify-email', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, []))) {
return;
}
return verifyEmail(request.body as Record<string, unknown>, requestLocale(request));
});
app.post('/api/auth/login', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, [loginEmailRateLimit]))) {
return;
}
return loginUser(request.body as Record<string, unknown>, requestLocale(request));
});
app.post('/api/auth/request-password-reset', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, [passwordResetEmailRateLimit, passwordResetRouteIpRateLimit]))) {
return;
}
return requestPasswordReset(request.body as Record<string, unknown>, requestLocale(request));
});
app.post('/api/auth/reset-password', async (request, reply) => {
if (!(await enforceAuthRateLimits(request, reply, [passwordResetRouteIpRateLimit]))) {
return;
}
return resetPassword(request.body as Record<string, unknown>, requestLocale(request));
});
app.get('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
return { user };
});
app.patch('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
if (!(await enforceUserRateLimits(request, reply, user, 'accountWrite'))) {
return;
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
});
app.patch('/api/auth/me/password', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
if (!user || !token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
if (!(await enforceUserRateLimits(request, reply, user, 'accountWrite'))) {
return;
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return changeCurrentUserPassword(user.id, payload, token, requestLocale(request));
});
app.get('/api/auth/referral', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
return { referral: await getReferralSummary(user.id) };
});
app.get('/api/notifications', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? listNotifications(user.id, request.query as Record<string, string | string[] | undefined>) : undefined;
});
app.post('/api/notifications/ws-ticket', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? createNotificationWebSocketTicket(user.id) : undefined;
});
app.post('/api/notifications/read-all', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? markAllNotificationsRead(user.id) : undefined;
});
app.post('/api/notifications/:id/read', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const result = await markNotificationRead(Number(id), user.id);
return result.notification ? result : notFound(reply, request);
});
app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
if (token) {
await logoutSession(token);
}
return reply.code(204).send();
});
app.get('/api/admin/users', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.users.read');
return user ? listAdminUsers() : undefined;
});
app.put('/api/admin/users/:id/roles', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.users.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
return updateAdminUserRoles(Number(id), request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/roles', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.roles.read');
return user ? listRoles() : undefined;
});
app.post('/api/admin/roles', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.create', 'adminWrite');
return user ? reply.code(201).send(await createRole(request.body as Record<string, unknown>)) : undefined;
});
app.put('/api/admin/roles/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
return updateRole(Number(id), request.body as Record<string, unknown>);
});
app.put('/api/admin/roles/:id/permissions', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
return updateRolePermissions(Number(id), request.body as Record<string, unknown>);
});
app.delete('/api/admin/roles/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.roles.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
await deleteRole(Number(id));
return reply.code(204).send();
});
app.get('/api/admin/permissions', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.permissions.read');
return user ? listPermissions() : undefined;
});
app.post('/api/admin/permissions', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.create', 'adminWrite');
return user ? reply.code(201).send(await createPermission(request.body as Record<string, unknown>)) : undefined;
});
app.put('/api/admin/permissions/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
return updatePermission(Number(id), request.body as Record<string, unknown>);
});
app.delete('/api/admin/permissions/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.permissions.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
await deletePermission(Number(id));
return reply.code(204).send();
});
app.get('/api/languages', async () => listLanguages());
app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request)));
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/project-updates', async (request) =>
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
);
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/users/:id/profile', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const profile = await getPublicUserProfile(Number(id), user?.id ?? null);
return profile ? { profile } : notFound(reply, request);
});
app.put('/api/users/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const profile = await followUser(user.id, Number(id));
return profile ? { profile } : notFound(reply, request);
});
app.delete('/api/users/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const profile = await unfollowUser(user.id, Number(id));
return profile ? { profile } : notFound(reply, request);
});
app.get('/api/users/:id/life-posts', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const canViewAll = user
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
: false;
const posts = await listUserLifePosts(
Number(id),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
requestLocale(request),
canViewAll
);
return posts ? posts : notFound(reply, request);
});
app.get('/api/users/:id/reactions', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const reactions = await listUserReactionActivities(
Number(id),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
requestLocale(request)
);
return reactions ? reactions : notFound(reply, request);
});
app.get('/api/users/:id/comments', async (request, reply) => {
const { id } = request.params as { id: string };
const comments = await listUserCommentActivities(
Number(id),
request.query as Record<string, string | string[] | undefined>,
requestLocale(request)
);
return comments ? comments : notFound(reply, request);
});
app.get('/api/life-posts', async (request) => {
const user = await optionalUser(request);
const canViewAll = user
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
: false;
return listLifePosts(
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
requestLocale(request),
canViewAll
);
});
app.get('/api/life-posts/following', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const canViewAll = userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any');
return listFollowingLifePosts(
user.id,
request.query as Record<string, string | string[] | undefined>,
requestLocale(request),
canViewAll
);
});
app.get('/api/life-posts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const canViewAll = user
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
: false;
const post = await getLifePost(Number(id), user?.id ?? null, requestLocale(request), canViewAll);
return post ? post : notFound(reply, request);
});
app.get('/api/life-posts/:id/reactions', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const canViewAll = user
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
: false;
const reactions = await listLifePostReactionUsers(
Number(id),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return reactions ? reactions : notFound(reply, request);
});
app.get('/api/life-posts/:postId/comments', async (request, reply) => {
const { postId } = request.params as { postId: string };
const user = await optionalUser(request);
const canViewAll = user ? userHasPermission(user, 'life.comments.delete-any') : false;
const comments = await listLifeComments(
Number(postId),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return comments ? comments : notFound(reply, request);
});
app.post('/api/life-posts', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.posts.create', 'communityWrite');
return user
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.post('/api/life-posts/:postId/comments', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.create', 'communityWrite');
if (!user) {
return;
}
const { postId } = request.params as { postId: string };
const comment = await createLifeComment(Number(postId), request.body as Record<string, unknown>, user.id);
return comment ? reply.code(201).send(comment) : notFound(reply, request);
});
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.create', 'communityWrite');
if (!user) {
return;
}
const { postId, commentId } = request.params as { postId: string; commentId: string };
const comment = await createLifeCommentReply(
Number(postId),
Number(commentId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : notFound(reply, request);
});
app.put('/api/life-posts/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['life.posts.update', 'life.posts.update-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await updateLifePost(
Number(id),
request.body as Record<string, unknown>,
user.id,
requestLocale(request),
userHasPermission(user, 'life.posts.update-any')
);
return post ? post : notFound(reply, request);
});
app.post('/api/life-posts/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['life.posts.update', 'life.posts.update-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await retryLifePostModeration(
Number(id),
user.id,
requestLocale(request),
userHasPermission(user, 'life.posts.update-any')
);
return post ? post : notFound(reply, request);
});
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.put('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostRating(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostRating(Number(id), user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['life.posts.delete', 'life.posts.delete-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifePost(Number(id), user.id, userHasPermission(user, 'life.posts.delete-any'));
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.delete('/api/life-comments/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['life.comments.delete', 'life.comments.delete-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifeComment(Number(id), user.id, userHasPermission(user, 'life.comments.delete-any'));
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/life-comments/:id/restore', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.delete', 'communityWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await restoreLifeComment(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.put('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['life.comments.create', 'life.comments.delete-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await retryLifeCommentModeration(
Number(id),
user.id,
userHasPermission(user, 'life.comments.delete-any')
);
return comment ? comment : notFound(reply, request);
});
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
const user = await optionalUser(request);
const canViewAll = user ? userHasPermission(user, 'discussions.comments.delete-any') : false;
const comments = await listEntityDiscussionComments(
entityType,
Number(entityId),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return comments ? comments : notFound(reply, request);
});
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.create', 'communityWrite');
if (!user) {
return;
}
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
const comment = await createEntityDiscussionComment(
entityType,
Number(entityId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : notFound(reply, request);
});
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.create', 'communityWrite');
if (!user) {
return;
}
const { entityType, entityId, commentId } = request.params as {
entityType: string;
entityId: string;
commentId: string;
};
const comment = await createEntityDiscussionReply(
entityType,
Number(entityId),
Number(commentId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : notFound(reply, request);
});
app.delete('/api/discussions/comments/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['discussions.comments.delete', 'discussions.comments.delete-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteEntityDiscussionComment(
Number(id),
user.id,
userHasPermission(user, 'discussions.comments.delete-any')
);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
reply,
['discussions.comments.create', 'discussions.comments.delete-any'],
'communityWrite'
);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await retryEntityDiscussionCommentModeration(
Number(id),
user.id,
userHasPermission(user, 'discussions.comments.delete-any')
);
return comment ? comment : notFound(reply, request);
});
app.put('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/pokemon/fetch-options', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch');
return user
? listPokemonFetchOptions(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
: undefined;
});
app.get('/api/pokemon/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const pokemon = await getPokemon(Number(id), requestLocale(request));
if (!pokemon) {
return notFound(reply, request);
}
return pokemon;
});
app.post('/api/pokemon', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.create', 'wikiWrite');
return user
? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.post('/api/pokemon/fetch', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch');
return user ? fetchPokemonData(request.body as Record<string, unknown>, user.id) : undefined;
});
app.post('/api/pokemon/image-options', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.fetch', 'fetch');
return user ? fetchPokemonImageOptions(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/uploads/:entityType', async (request, reply) => {
const { entityType } = request.params as { entityType: string };
if (!isUploadEntityType(entityType)) {
return notFound(reply, request);
}
const permissionKey =
entityType === 'pokemon'
? 'pokemon.upload'
: entityType === 'items'
? 'items.upload'
: entityType === 'habitats'
? 'habitats.upload'
: 'ancient-artifacts.upload';
const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload');
if (!user) {
return;
}
let file: MultipartFile | undefined;
try {
file = await request.file();
} catch (error) {
const multipartError = error as Error & { code?: string };
if (multipartError.code === 'FST_REQ_FILE_TOO_LARGE') {
throw badRequest('server.validation.imageUploadContentInvalid');
}
throw error;
}
return reply.code(201).send(await saveEntityImageUpload(entityType, file, user));
});
app.put('/api/pokemon/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!pokemon) {
return notFound(reply, request);
}
return pokemon;
});
app.delete('/api/pokemon/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deletePokemon(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/habitats', async (request) =>
listHabitats(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/habitats/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const habitat = await getHabitat(Number(id), requestLocale(request));
if (!habitat) {
return notFound(reply, request);
}
return habitat;
});
app.post('/api/habitats', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'habitats.create', 'wikiWrite');
return user
? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/habitats/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'habitats.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!habitat) {
return notFound(reply, request);
}
return habitat;
});
app.delete('/api/habitats/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'habitats.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteHabitat(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/items', async (request) =>
listItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/items/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const item = await getItem(Number(id), requestLocale(request));
if (!item) {
return notFound(reply, request);
}
return item;
});
app.post('/api/items', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite');
return user
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/items/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!item) {
return notFound(reply, request);
}
return item;
});
app.delete('/api/items/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteItem(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/ancient-artifacts', async (request) =>
listAncientArtifacts(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/ancient-artifacts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artifact = await getAncientArtifact(Number(id), requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.post('/api/ancient-artifacts', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.create', 'wikiWrite');
return user
? reply.code(201).send(await createAncientArtifact(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const artifact = await updateAncientArtifact(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.delete('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteAncientArtifact(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/recipes', async (request) =>
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const recipe = await getRecipe(Number(id), requestLocale(request));
if (!recipe) {
return notFound(reply, request);
}
return recipe;
});
app.post('/api/recipes', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.create', 'wikiWrite');
return user
? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/recipes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!recipe) {
return notFound(reply, request);
}
return recipe;
});
app.delete('/api/recipes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteRecipe(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/dish', async (request) => listDish(requestLocale(request)));
app.post('/api/admin/dish/categories', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDishCategory(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/categories/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishCategories(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const category = await updateDishCategory(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return category ? category : notFound(reply, request);
});
app.delete('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDishCategory(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/dish/dishes', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDish(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/dishes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const dish = await updateDish(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return dish ? dish : notFound(reply, request);
});
app.delete('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDish(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/daily-checklist', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite');
return user
? reply
.code(201)
.send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/daily-checklist/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.order', 'wikiWrite');
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const item = await updateDailyChecklistItem(
Number(id),
request.body as Record<string, unknown>,
user.id,
requestLocale(request)
);
return item ? item : notFound(reply, request);
});
app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDailyChecklistItem(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.put('/api/admin/pokemon/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite');
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/items/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/ancient-artifacts/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.order', 'wikiWrite');
return user ? reorderAncientArtifacts(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/recipes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/habitats/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'habitats.order', 'wikiWrite');
return user ? reorderHabitats(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.get('/api/admin/languages', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.languages.read');
return user ? listLanguages(true) : undefined;
});
app.post('/api/admin/languages', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.create', 'adminWrite');
return user ? reply.code(201).send(await createLanguage(request.body as Record<string, unknown>)) : undefined;
});
app.put('/api/admin/languages/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.order', 'adminWrite');
return user ? reorderLanguages(request.body as Record<string, unknown>) : undefined;
});
app.put('/api/admin/languages/:code', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.update', 'adminWrite');
if (!user) {
return;
}
const { code } = request.params as { code: string };
return updateLanguage(code, request.body as Record<string, unknown>);
});
app.delete('/api/admin/languages/:code', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.languages.delete', 'adminWrite');
if (!user) {
return;
}
const { code } = request.params as { code: string };
const deleted = await deleteLanguage(code);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/admin/system-wordings', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.wordings.read');
return user ? listSystemWordingRows(request.query as Record<string, unknown>) : undefined;
});
app.put('/api/admin/system-wordings/:key', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.wordings.update', 'adminWrite');
if (!user) {
return;
}
const { key } = request.params as { key: string };
return updateSystemWordingValue(key, request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/ai-moderation', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.ai-moderation.read');
return user ? getAiModerationSettings() : undefined;
});
app.put('/api/admin/ai-moderation', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.ai-moderation.update', 'adminWrite');
if (!user) {
return;
}
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
return user ? getRateLimitSettings() : undefined;
});
app.put('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.rate-limits.update', 'adminWrite');
if (!user) {
return;
}
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/data-tools/summary', async (request, reply) => {
const user = await requireAnyPermission(request, reply, ['admin.data.export', 'admin.data.import']);
return user ? getAdminDataToolsSummary() : undefined;
});
app.post('/api/admin/data-tools/export', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.export', 'adminWrite');
return user ? exportAdminData(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/admin/data-tools/import', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
});
app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {
return;
}
const { type } = request.params as { type: string };
if (!isConfigType(type)) {
return notFound(reply, request);
}
return listConfig(type, requestLocale(request));
});
app.post('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.create', 'adminWrite');
if (!user) {
return;
}
const { type } = request.params as { type: string };
if (!isConfigType(type)) {
return notFound(reply, request);
}
return reply
.code(201)
.send(await createConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request)));
});
app.put('/api/admin/config/:type/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.order', 'adminWrite');
if (!user) {
return;
}
const { type } = request.params as { type: string };
if (!isConfigType(type)) {
return notFound(reply, request);
}
return reorderConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request));
});
app.put('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) {
return;
}
const { type, id } = request.params as { type: string; id: string };
if (!isConfigType(type)) {
return notFound(reply, request);
}
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return config ? config : notFound(reply, request);
});
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.delete', 'adminWrite');
if (!user) {
return;
}
const { type, id } = request.params as { type: string; id: string };
if (!isConfigType(type)) {
return notFound(reply, request);
}
const deleted = await deleteConfig(type, Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
const port = Number(process.env.BACKEND_PORT ?? 3001);
try {
await initializeDatabase();
await syncSystemWordingCatalog();
await startAiModerationWorker(app.log);
setupNotificationWebSocketServer(app.server, app.log);
await app.listen({ host: '0.0.0.0', port });
} catch (error) {
app.log.error(error);
await pool.end();
process.exit(1);
}