Files
pokopiawiki.tootaio.com/backend/src/uploads.ts
xiaomai 7aa80430d9 refactor(api): remove internal metadata from image upload responses
Omit entity details, original filename, MIME type, and file size from payloads
Update backend SQL queries and frontend interfaces to align with design specs
2026-05-03 15:24:27 +08:00

266 lines
7.9 KiB
TypeScript

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;
path: string;
url: string;
uploadedAt: Date;
uploadedBy: { id: number; displayName: string } | null;
};
type UploadRow = {
id: number;
path: string;
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 {
id: row.id,
path: row.path,
uploadedAt: row.uploadedAt,
uploadedBy: row.uploadedBy,
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,
path,
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.path,
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]
);
}