feat(wiki): add community image upload for wiki entities

Support uploading images for Pokemon, Items, and Habitats
Track upload history in new entity_image_uploads table
Update entity cards to display uploaded images and usage ribbons
This commit is contained in:
2026-05-03 01:08:45 +08:00
parent 36e10a06b0
commit 784cbdacd1
23 changed files with 1407 additions and 102 deletions

View File

@@ -1,6 +1,9 @@
import cors from '@fastify/cors';
import multipart, { type MultipartFile } from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import Fastify from 'fastify';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { mkdir } from 'node:fs/promises';
import {
getUserBySessionToken,
loginUser,
@@ -81,6 +84,12 @@ import {
systemMessage,
updateSystemWordingValue
} from './systemWordingQueries.ts';
import {
imageUploadMaxBytes,
isUploadEntityType,
saveEntityImageUpload,
uploadRoot
} from './uploads.ts';
const app = Fastify({
logger: true
@@ -92,6 +101,19 @@ await app.register(cors, {
origin: process.env.FRONTEND_ORIGIN ?? true
});
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);
@@ -137,6 +159,12 @@ function serverMessage(
return systemMessage(locale, `server.errors.${key}`);
}
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') });
}
@@ -408,6 +436,31 @@ app.post('/api/pokemon/image-options', async (request, reply) => {
return user ? fetchPokemonImageOptions(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/uploads/:entityType', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { entityType } = request.params as { entityType: string };
if (!isUploadEntityType(entityType)) {
return notFound(reply, request);
}
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 requireVerifiedUser(request, reply);
if (!user) {