import 'server-only'; import { mkdir, writeFile, readFile, unlink } from 'node:fs/promises'; import path from 'node:path'; import { newId } from '@rareindex/shared'; import { db, uploads } from '@/lib/db'; const MAX_BYTES: Record = { avatar: 2 * 1024 * 1024, item_photo: 8 * 1024 * 1024 }; const ALLOWED: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; function uploadsRoot(): string { return path.resolve(process.env.RI_DATA_DIR ?? './data', 'uploads'); } /** Sniff the real type from magic bytes; never trust the declared MIME. */ function sniff(buf: Buffer): string | null { if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; if (buf.length > 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'; if (buf.length > 12 && buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'; return null; } /** Cheap dimension probe for PNG/JPEG/WebP (no image library needed). */ function dimensions(buf: Buffer, mime: string): { width: number; height: number } | null { try { if (mime === 'image/png') return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; if (mime === 'image/jpeg') { let i = 2; while (i < buf.length) { if (buf[i] !== 0xff) return null; const marker = buf[i + 1]!; const len = buf.readUInt16BE(i + 2); if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; i += 2 + len; } } if (mime === 'image/webp') { const chunk = buf.subarray(12, 16).toString('ascii'); if (chunk === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) }; if (chunk === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }; } } catch { return null; } return null; } export async function storeUpload(userId: string, kind: 'avatar' | 'item_photo', file: File): Promise<{ id: string; url: string } | { error: string }> { const max = MAX_BYTES[kind]!; if (file.size === 0) return { error: 'Empty file.' }; if (file.size > max) return { error: `File too large (max ${Math.round(max / 1024 / 1024)} MB).` }; const buf = Buffer.from(await file.arrayBuffer()); const mime = sniff(buf); if (!mime || !ALLOWED[mime]) return { error: 'Only JPEG, PNG and WebP images are accepted.' }; const id = newId('image'); const rel = path.join(userId, `${id}.${ALLOWED[mime]}`); const abs = path.join(uploadsRoot(), rel); await mkdir(path.dirname(abs), { recursive: true }); await writeFile(abs, buf); const dim = dimensions(buf, mime); await db().insert(uploads).values({ id, userId, kind, path: rel, mime, bytes: buf.length, width: dim?.width ?? null, height: dim?.height ?? null }); return { id, url: `/api/account/uploads/${id}` }; } export async function readUpload(id: string): Promise<{ buf: Buffer; mime: string; userId: string } | null> { const rows = await db().select().from(uploads).where((await import('@/lib/db')).eq(uploads.id, id)).limit(1); const row = rows[0]; if (!row) return null; try { const buf = await readFile(path.join(uploadsRoot(), row.path)); return { buf, mime: row.mime, userId: row.userId }; } catch { return null; } } export async function deleteUpload(userId: string, id: string): Promise { const { and, eq } = await import('@/lib/db'); const rows = await db().select().from(uploads).where(and(eq(uploads.id, id), eq(uploads.userId, userId))).limit(1); const row = rows[0]; if (!row) return; await unlink(path.join(uploadsRoot(), row.path)).catch(() => {}); await db().delete(uploads).where(eq(uploads.id, id)); }