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:
@@ -1,5 +1,12 @@
|
||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||
import { pool, query, queryOne } from './db.ts';
|
||||
import {
|
||||
isUploadImagePath,
|
||||
linkEntityImageUpload,
|
||||
listEntityImageUploads,
|
||||
uploadImageUrl,
|
||||
uploadPublicBaseUrl
|
||||
} from './uploads.ts';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
@@ -77,6 +84,12 @@ type PokemonImage = {
|
||||
version: string;
|
||||
variant: string;
|
||||
description: string;
|
||||
source?: 'sprite' | 'upload';
|
||||
};
|
||||
|
||||
type EntityImageValue = {
|
||||
path: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
|
||||
@@ -143,6 +156,7 @@ type ItemPayload = {
|
||||
noRecipe: boolean;
|
||||
acquisitionMethodIds: number[];
|
||||
tagIds: number[];
|
||||
imagePath: string;
|
||||
};
|
||||
|
||||
type RecipePayload = {
|
||||
@@ -236,6 +250,7 @@ type LifePostsPage = {
|
||||
type HabitatPayload = {
|
||||
name: string;
|
||||
translations: TranslationInput;
|
||||
imagePath: string;
|
||||
recipeItems: IdQuantity[];
|
||||
pokemonAppearances: Array<{
|
||||
pokemonId: number;
|
||||
@@ -282,6 +297,7 @@ type PokemonChangeSource = {
|
||||
};
|
||||
type ItemChangeSource = {
|
||||
name: string;
|
||||
image: EntityImageValue | null;
|
||||
category: { name: string };
|
||||
usage: { name: string } | null;
|
||||
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
||||
@@ -291,6 +307,7 @@ type ItemChangeSource = {
|
||||
};
|
||||
type HabitatChangeSource = {
|
||||
name: string;
|
||||
image: EntityImageValue | null;
|
||||
recipe: Array<{ name: string; quantity: number }>;
|
||||
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
||||
};
|
||||
@@ -360,6 +377,25 @@ function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function uploadedImageJson(pathExpression: string): string {
|
||||
return `
|
||||
CASE WHEN ${pathExpression} <> '' THEN json_build_object(
|
||||
'path', ${pathExpression},
|
||||
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression}
|
||||
) ELSE NULL END
|
||||
`;
|
||||
}
|
||||
|
||||
function imagePathLabel(path: string | null | undefined): string {
|
||||
const cleanPath = path?.trim() ?? '';
|
||||
if (cleanPath === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = cleanPath.split('/');
|
||||
return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
|
||||
}
|
||||
|
||||
function localizedField(
|
||||
entityType: EntityType,
|
||||
entityIdExpression: string,
|
||||
@@ -559,6 +595,17 @@ function cleanOptionalText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string {
|
||||
const imagePath = cleanOptionalText(value);
|
||||
if (imagePath === '') {
|
||||
return '';
|
||||
}
|
||||
if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
|
||||
throw validationError('server.validation.imagePathInvalid');
|
||||
}
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
function cleanIds(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -1039,7 +1086,7 @@ function pokemonSpriteUrl(path: string): string {
|
||||
}
|
||||
|
||||
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
||||
return { ...candidate, url: pokemonSpriteUrl(candidate.path) };
|
||||
return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
|
||||
}
|
||||
|
||||
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
||||
@@ -1223,7 +1270,10 @@ function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
||||
}
|
||||
|
||||
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
||||
return image ? `${image.style} - ${image.version} - ${image.variant}` : '';
|
||||
if (!image) {
|
||||
return '';
|
||||
}
|
||||
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
|
||||
}
|
||||
|
||||
function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null {
|
||||
@@ -1238,6 +1288,21 @@ function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isUploadImagePath(path)) {
|
||||
if (!path.startsWith('pokemon/')) {
|
||||
throw validationError('server.validation.imagePathInvalid');
|
||||
}
|
||||
return {
|
||||
path,
|
||||
url: uploadImageUrl(path),
|
||||
style: 'Upload',
|
||||
version: 'Community upload',
|
||||
variant: `#${pokemonId}`,
|
||||
description: '',
|
||||
source: 'upload'
|
||||
};
|
||||
}
|
||||
|
||||
const image = pokemonImageCandidateForPath(pokemonId, path);
|
||||
if (!image) {
|
||||
throw validationError('Pokemon image path is invalid');
|
||||
@@ -1653,6 +1718,7 @@ async function itemEditChanges(
|
||||
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||
|
||||
pushChange(changes, 'Name', before.name, after.name);
|
||||
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
||||
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
||||
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
||||
@@ -1684,6 +1750,7 @@ async function habitatEditChanges(
|
||||
.join(' / ');
|
||||
|
||||
pushChange(changes, 'Name', before.name, after.name);
|
||||
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
||||
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
||||
|
||||
@@ -1749,14 +1816,27 @@ function pokemonProjection(locale: string): string {
|
||||
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
||||
p.weight_pounds AS "weightPounds",
|
||||
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
||||
CASE WHEN p.image_path <> '' THEN json_build_object(
|
||||
'path', p.image_path,
|
||||
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
|
||||
'style', p.image_style,
|
||||
'version', p.image_version,
|
||||
'variant', p.image_variant,
|
||||
'description', p.image_description
|
||||
) ELSE NULL END AS image,
|
||||
CASE
|
||||
WHEN p.image_path LIKE '/sprites/%' THEN json_build_object(
|
||||
'path', p.image_path,
|
||||
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
|
||||
'style', p.image_style,
|
||||
'version', p.image_version,
|
||||
'variant', p.image_variant,
|
||||
'description', p.image_description,
|
||||
'source', 'sprite'
|
||||
)
|
||||
WHEN p.image_path <> '' THEN json_build_object(
|
||||
'path', p.image_path,
|
||||
'url', ${sqlLiteral(uploadPublicBaseUrl)} || p.image_path,
|
||||
'style', 'Upload',
|
||||
'version', 'Community upload',
|
||||
'variant', p.name,
|
||||
'description', '',
|
||||
'source', 'upload'
|
||||
)
|
||||
ELSE NULL
|
||||
END AS image,
|
||||
json_build_object(
|
||||
'hp', p.hp,
|
||||
'attack', p.attack,
|
||||
@@ -2951,7 +3031,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
const relatedSkillName = localizedName('skills', 'related_skill', locale);
|
||||
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
|
||||
|
||||
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory] = await Promise.all([
|
||||
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
@@ -3068,7 +3148,8 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
getEditHistory('pokemon', id)
|
||||
getEditHistory('pokemon', id),
|
||||
listEntityImageUploads('pokemon', id)
|
||||
]);
|
||||
|
||||
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
||||
@@ -3083,7 +3164,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory };
|
||||
return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory, imageHistory };
|
||||
}
|
||||
|
||||
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
@@ -3245,6 +3326,7 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
userId
|
||||
]
|
||||
);
|
||||
await linkEntityImageUpload(client, 'pokemon', cleanPayload.id, cleanPayload.image?.path, cleanPayload.name);
|
||||
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
|
||||
@@ -3308,6 +3390,7 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
if (result.rowCount === 0) {
|
||||
return false;
|
||||
}
|
||||
await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
|
||||
await replacePokemonRelations(client, id, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
||||
@@ -3343,6 +3426,7 @@ export async function listHabitats(locale = defaultLocale) {
|
||||
h.name AS "baseName",
|
||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||
${uploadedImageJson('h.image_path')} AS image,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM habitat_recipe_items hri
|
||||
@@ -3378,6 +3462,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
h.name AS "baseName",
|
||||
${translationsSelect('habitats', 'h.id')} AS translations,
|
||||
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
||||
${uploadedImageJson('h.image_path')} AS image,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
||||
FROM habitat_recipe_items hri
|
||||
@@ -3395,7 +3480,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [pokemon, editHistory] = await Promise.all([
|
||||
const [pokemon, editHistory, imageHistory] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
@@ -3413,10 +3498,11 @@ export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
getEditHistory('habitats', id)
|
||||
getEditHistory('habitats', id),
|
||||
listEntityImageUploads('habitats', id)
|
||||
]);
|
||||
|
||||
return { ...habitat, pokemon, editHistory };
|
||||
return { ...habitat, pokemon, editHistory, imageHistory };
|
||||
}
|
||||
|
||||
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
||||
@@ -3453,6 +3539,7 @@ function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
||||
return {
|
||||
name: cleanName(payload.name, 'Habitat name is required'),
|
||||
translations: cleanTranslations(payload.translations, ['name']),
|
||||
imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
|
||||
recipeItems: cleanQuantities(payload.recipeItems),
|
||||
pokemonAppearances: [...pokemonAppearances.values()]
|
||||
};
|
||||
@@ -3488,13 +3575,14 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
|
||||
const sortOrder = await nextSortOrder(client, 'habitats');
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO habitats (name, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
INSERT INTO habitats (name, image_path, sort_order, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
RETURNING id
|
||||
`,
|
||||
[cleanPayload.name, sortOrder, userId]
|
||||
[cleanPayload.name, cleanPayload.imagePath, sortOrder, userId]
|
||||
);
|
||||
const habitatId = result.rows[0].id;
|
||||
await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
|
||||
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
|
||||
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
||||
@@ -3509,12 +3597,13 @@ export async function updateHabitat(id: number, payload: Record<string, unknown>
|
||||
|
||||
const updated = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
'UPDATE habitats SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
||||
[cleanPayload.name, userId, id]
|
||||
'UPDATE habitats SET name = $1, image_path = $2, updated_by_user_id = $3, updated_at = now() WHERE id = $4',
|
||||
[cleanPayload.name, cleanPayload.imagePath, userId, id]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
return false;
|
||||
}
|
||||
await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name);
|
||||
await replaceHabitatRelations(client, id, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
|
||||
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
|
||||
@@ -3551,6 +3640,7 @@ function itemProjection(locale: string): string {
|
||||
i.name AS "baseName",
|
||||
${translationsSelect('items', 'i.id')} AS translations,
|
||||
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||
${uploadedImageJson('i.image_path')} AS image,
|
||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
||||
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
|
||||
json_build_object(
|
||||
@@ -3649,7 +3739,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||
const skillName = localizedName('skills', 's', locale);
|
||||
|
||||
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory] = await Promise.all([
|
||||
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT am.id, ${acquisitionMethodName} AS name
|
||||
@@ -3737,10 +3827,11 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
getEditHistory('items', id)
|
||||
getEditHistory('items', id),
|
||||
listEntityImageUploads('items', id)
|
||||
]);
|
||||
|
||||
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory };
|
||||
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory };
|
||||
}
|
||||
|
||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
@@ -3758,7 +3849,8 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
patternEditable: Boolean(payload.patternEditable),
|
||||
noRecipe: Boolean(payload.noRecipe),
|
||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||
tagIds: cleanIds(payload.tagIds)
|
||||
tagIds: cleanIds(payload.tagIds),
|
||||
imagePath: cleanUploadImagePath(payload.imagePath, 'items')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3807,11 +3899,12 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
dual_dyeable,
|
||||
pattern_editable,
|
||||
no_recipe,
|
||||
image_path,
|
||||
sort_order,
|
||||
created_by_user_id,
|
||||
updated_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -3822,11 +3915,13 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable,
|
||||
cleanPayload.noRecipe,
|
||||
cleanPayload.imagePath,
|
||||
sortOrder,
|
||||
userId
|
||||
]
|
||||
);
|
||||
const itemId = result.rows[0].id;
|
||||
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||
await replaceItemRelations(client, itemId, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
|
||||
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||
@@ -3851,9 +3946,10 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
||||
dual_dyeable = $5,
|
||||
pattern_editable = $6,
|
||||
no_recipe = $7,
|
||||
updated_by_user_id = $8,
|
||||
image_path = $8,
|
||||
updated_by_user_id = $9,
|
||||
updated_at = now()
|
||||
WHERE id = $9
|
||||
WHERE id = $10
|
||||
`,
|
||||
[
|
||||
cleanPayload.name,
|
||||
@@ -3863,6 +3959,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable,
|
||||
cleanPayload.noRecipe,
|
||||
cleanPayload.imagePath,
|
||||
userId,
|
||||
id
|
||||
]
|
||||
@@ -3870,6 +3967,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
||||
if (result.rowCount === 0) {
|
||||
return false;
|
||||
}
|
||||
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
||||
await replaceItemRelations(client, id, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
|
||||
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
286
backend/src/uploads.ts
Normal file
286
backend/src/uploads.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { MultipartFile } from '@fastify/multipart';
|
||||
import type { PoolClient } from 'pg';
|
||||
import type { AuthUser } from './auth.ts';
|
||||
import { query, queryOne } from './db.ts';
|
||||
|
||||
export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
|
||||
|
||||
export type EntityImageUpload = {
|
||||
id: number;
|
||||
entityType: UploadEntityType;
|
||||
entityId: number | null;
|
||||
entityName: string;
|
||||
path: string;
|
||||
url: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
uploadedAt: Date;
|
||||
uploadedBy: { id: number; displayName: string } | null;
|
||||
};
|
||||
|
||||
type UploadRow = {
|
||||
id: number;
|
||||
entityType: UploadEntityType;
|
||||
entityId: number | null;
|
||||
entityName: string;
|
||||
path: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
uploadedAt: Date;
|
||||
uploadedBy: { id: number; displayName: string } | null;
|
||||
};
|
||||
|
||||
type MultipartField = {
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats']);
|
||||
const imageMimeTypes = new Map([
|
||||
['image/png', '.png'],
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/webp', '.webp'],
|
||||
['image/gif', '.gif']
|
||||
]);
|
||||
|
||||
const backendPublicOrigin = process.env.BACKEND_PUBLIC_ORIGIN ?? `http://localhost:${process.env.BACKEND_PORT ?? 3001}`;
|
||||
export const imageUploadMaxBytes = 3 * 1024 * 1024;
|
||||
export const uploadRoot = path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), 'uploads'));
|
||||
export const uploadPublicBaseUrl = (process.env.UPLOAD_PUBLIC_BASE_URL ?? `${backendPublicOrigin}/uploads/`).replace(/\/?$/, '/');
|
||||
|
||||
export function isUploadEntityType(value: string): value is UploadEntityType {
|
||||
return uploadEntityTypes.has(value as UploadEntityType);
|
||||
}
|
||||
|
||||
export function isUploadImagePath(value: string | null | undefined): boolean {
|
||||
const cleanPath = value?.trim() ?? '';
|
||||
if (cleanPath === '' || cleanPath.startsWith('/') || cleanPath.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [entityType] = cleanPath.split('/');
|
||||
return isUploadEntityType(entityType);
|
||||
}
|
||||
|
||||
export function uploadImageUrl(relativePath: string): string {
|
||||
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
function validationError(message: string): Error & { statusCode: number } {
|
||||
const error = new Error(message) as Error & { statusCode: number };
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function fieldValue(fields: Record<string, unknown> | undefined, fieldName: string): string {
|
||||
const field = fields?.[fieldName];
|
||||
if (field && typeof field === 'object' && 'value' in field) {
|
||||
const value = (field as MultipartField).value;
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(value: string): number | null {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
const segment = value
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%?&\u0000-\u001F]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^\.+$/, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return segment || 'record';
|
||||
}
|
||||
|
||||
function timestampForPath(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return [
|
||||
date.getUTCFullYear(),
|
||||
pad(date.getUTCMonth() + 1),
|
||||
pad(date.getUTCDate()),
|
||||
pad(date.getUTCHours()),
|
||||
pad(date.getUTCMinutes()),
|
||||
pad(date.getUTCSeconds())
|
||||
].join('');
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uniqueRelativePath(entityType: UploadEntityType, entityName: string, extension: string): Promise<string> {
|
||||
const entitySegment = safePathSegment(entityName);
|
||||
const dir = path.join(uploadRoot, entityType, entitySegment);
|
||||
const timestamp = timestampForPath();
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
for (let index = 1; index <= 99; index += 1) {
|
||||
const suffix = index === 1 ? '' : `-${index}`;
|
||||
const fileName = `${timestamp}${suffix}${extension}`;
|
||||
const candidate = path.join(dir, fileName);
|
||||
if (!(await fileExists(candidate))) {
|
||||
return `${entityType}/${entitySegment}/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
throw validationError('server.validation.imageUploadFailed');
|
||||
}
|
||||
|
||||
function hasValidImageSignature(mimeType: string, buffer: Buffer): boolean {
|
||||
if (mimeType === 'image/png') {
|
||||
return buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG';
|
||||
}
|
||||
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
}
|
||||
|
||||
if (mimeType === 'image/webp') {
|
||||
return buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||
}
|
||||
|
||||
if (mimeType === 'image/gif') {
|
||||
const signature = buffer.subarray(0, 6).toString('ascii');
|
||||
return signature === 'GIF87a' || signature === 'GIF89a';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapUploadRow(row: UploadRow): EntityImageUpload {
|
||||
return {
|
||||
...row,
|
||||
url: uploadImageUrl(row.path)
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveEntityImageUpload(
|
||||
entityType: UploadEntityType,
|
||||
file: MultipartFile | undefined,
|
||||
user: AuthUser
|
||||
): Promise<EntityImageUpload> {
|
||||
if (!file) {
|
||||
throw validationError('server.validation.imageUploadRequired');
|
||||
}
|
||||
|
||||
const extension = imageMimeTypes.get(file.mimetype);
|
||||
if (!extension) {
|
||||
throw validationError('server.validation.imageUploadTypeInvalid');
|
||||
}
|
||||
|
||||
const entityName = fieldValue(file.fields as Record<string, unknown>, 'entityName');
|
||||
if (entityName === '') {
|
||||
throw validationError('server.validation.imageUploadEntityNameRequired');
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
if (buffer.length === 0 || buffer.length > imageUploadMaxBytes || !hasValidImageSignature(file.mimetype, buffer)) {
|
||||
throw validationError('server.validation.imageUploadContentInvalid');
|
||||
}
|
||||
|
||||
const entityId = optionalPositiveInteger(fieldValue(file.fields as Record<string, unknown>, 'entityId'));
|
||||
const relativePath = await uniqueRelativePath(entityType, entityName, extension);
|
||||
const absolutePath = path.join(uploadRoot, relativePath);
|
||||
await writeFile(absolutePath, buffer, { flag: 'wx' });
|
||||
|
||||
const row = await queryOne<UploadRow>(
|
||||
`
|
||||
INSERT INTO entity_image_uploads (
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
path,
|
||||
original_filename,
|
||||
mime_type,
|
||||
byte_size,
|
||||
created_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING
|
||||
id,
|
||||
entity_type AS "entityType",
|
||||
entity_id AS "entityId",
|
||||
entity_name AS "entityName",
|
||||
path,
|
||||
original_filename AS "originalFilename",
|
||||
mime_type AS "mimeType",
|
||||
byte_size AS "byteSize",
|
||||
created_at AS "uploadedAt",
|
||||
json_build_object('id', $8::integer, 'displayName', $9::text) AS "uploadedBy"
|
||||
`,
|
||||
[entityType, entityId, entityName.trim(), relativePath, file.filename, file.mimetype, buffer.length, user.id, user.displayName]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
throw validationError('server.validation.imageUploadFailed');
|
||||
}
|
||||
|
||||
return mapUploadRow(row);
|
||||
}
|
||||
|
||||
export async function listEntityImageUploads(entityType: UploadEntityType, entityId: number): Promise<EntityImageUpload[]> {
|
||||
const rows = await query<UploadRow>(
|
||||
`
|
||||
SELECT
|
||||
upload.id,
|
||||
upload.entity_type AS "entityType",
|
||||
upload.entity_id AS "entityId",
|
||||
upload.entity_name AS "entityName",
|
||||
upload.path,
|
||||
upload.original_filename AS "originalFilename",
|
||||
upload.mime_type AS "mimeType",
|
||||
upload.byte_size AS "byteSize",
|
||||
upload.created_at AS "uploadedAt",
|
||||
CASE
|
||||
WHEN u.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
||||
END AS "uploadedBy"
|
||||
FROM entity_image_uploads upload
|
||||
LEFT JOIN users u ON u.id = upload.created_by_user_id
|
||||
WHERE upload.entity_type = $1
|
||||
AND upload.entity_id = $2
|
||||
ORDER BY upload.created_at DESC, upload.id DESC
|
||||
`,
|
||||
[entityType, entityId]
|
||||
);
|
||||
|
||||
return rows.map(mapUploadRow);
|
||||
}
|
||||
|
||||
export async function linkEntityImageUpload(
|
||||
client: PoolClient,
|
||||
entityType: UploadEntityType,
|
||||
entityId: number,
|
||||
imagePath: string | null | undefined,
|
||||
entityName: string
|
||||
): Promise<void> {
|
||||
if (!isUploadImagePath(imagePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE entity_image_uploads
|
||||
SET entity_id = $1,
|
||||
entity_name = $2
|
||||
WHERE entity_type = $3
|
||||
AND path = $4
|
||||
`,
|
||||
[entityId, entityName.trim(), entityType, imagePath]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user