SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.8 KB · 84 lines typescript
Raw Blame History
1import 'server-only';2import { mkdir, writeFile, readFile, unlink } from 'node:fs/promises';3import path from 'node:path';4import { newId } from '@rareindex/shared';5import { db, uploads } from '@/lib/db';67const MAX_BYTES: Record<string, number> = { avatar: 2 * 1024 * 1024, item_photo: 8 * 1024 * 1024 };8const ALLOWED: Record<string, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };910function uploadsRoot(): string {11  return path.resolve(process.env.RI_DATA_DIR ?? './data', 'uploads');12}1314/** Sniff the real type from magic bytes; never trust the declared MIME. */15function sniff(buf: Buffer): string | null {16  if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';17  if (buf.length > 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';18  if (buf.length > 12 && buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';19  return null;20}2122/** Cheap dimension probe for PNG/JPEG/WebP (no image library needed). */23function dimensions(buf: Buffer, mime: string): { width: number; height: number } | null {24  try {25    if (mime === 'image/png') return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };26    if (mime === 'image/jpeg') {27      let i = 2;28      while (i < buf.length) {29        if (buf[i] !== 0xff) return null;30        const marker = buf[i + 1]!;31        const len = buf.readUInt16BE(i + 2);32        if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };33        i += 2 + len;34      }35    }36    if (mime === 'image/webp') {37      const chunk = buf.subarray(12, 16).toString('ascii');38      if (chunk === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };39      if (chunk === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };40    }41  } catch {42    return null;43  }44  return null;45}4647export async function storeUpload(userId: string, kind: 'avatar' | 'item_photo', file: File): Promise<{ id: string; url: string } | { error: string }> {48  const max = MAX_BYTES[kind]!;49  if (file.size === 0) return { error: 'Empty file.' };50  if (file.size > max) return { error: `File too large (max ${Math.round(max / 1024 / 1024)} MB).` };51  const buf = Buffer.from(await file.arrayBuffer());52  const mime = sniff(buf);53  if (!mime || !ALLOWED[mime]) return { error: 'Only JPEG, PNG and WebP images are accepted.' };54  const id = newId('image');55  const rel = path.join(userId, `${id}.${ALLOWED[mime]}`);56  const abs = path.join(uploadsRoot(), rel);57  await mkdir(path.dirname(abs), { recursive: true });58  await writeFile(abs, buf);59  const dim = dimensions(buf, mime);60  await db().insert(uploads).values({ id, userId, kind, path: rel, mime, bytes: buf.length, width: dim?.width ?? null, height: dim?.height ?? null });61  return { id, url: `/api/account/uploads/${id}` };62}6364export async function readUpload(id: string): Promise<{ buf: Buffer; mime: string; userId: string } | null> {65  const rows = await db().select().from(uploads).where((await import('@/lib/db')).eq(uploads.id, id)).limit(1);66  const row = rows[0];67  if (!row) return null;68  try {69    const buf = await readFile(path.join(uploadsRoot(), row.path));70    return { buf, mime: row.mime, userId: row.userId };71  } catch {72    return null;73  }74}7576export async function deleteUpload(userId: string, id: string): Promise<void> {77  const { and, eq } = await import('@/lib/db');78  const rows = await db().select().from(uploads).where(and(eq(uploads.id, id), eq(uploads.userId, userId))).limit(1);79  const row = rows[0];80  if (!row) return;81  await unlink(path.join(uploadsRoot(), row.path)).catch(() => {});82  await db().delete(uploads).where(eq(uploads.id, id));83}84