import { Hono } from 'hono'; import { db, dbGet, dbAll, media } from '../db/index.js'; import { eq, and } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { generateId, getNow } from '../lib/utils.js'; import { getStorage, keyFromUrl } from '../lib/storage.js'; const mediaRouter = new Hono(); const MAX_FILE_SIZE = (Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB /** * Detect a real image type from the file's magic bytes (content sniffing). * Returns the canonical mime + extension, or null if the content is not an * allowed image. We deliberately ignore the client-supplied filename and * Content-Type so an attacker cannot store e.g. an .html/.svg payload. */ function detectImageType(buf: Buffer): { mime: string; ext: string } | null { if (buf.length < 12) return null; // JPEG: FF D8 FF if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { return { mime: 'image/jpeg', ext: '.jpg' }; } // PNG: 89 50 4E 47 0D 0A 1A 0A if ( buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a ) { return { mime: 'image/png', ext: '.png' }; } // GIF: "GIF87a" / "GIF89a" if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') { return { mime: 'image/gif', ext: '.gif' }; } // WEBP: "RIFF"...."WEBP" if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') { return { mime: 'image/webp', ext: '.webp' }; } // AVIF / HEIF: "....ftyp" with an avif/heic brand if (buf.toString('ascii', 4, 8) === 'ftyp') { const brand = buf.toString('ascii', 8, 12); if (brand === 'avif' || brand === 'avis') { return { mime: 'image/avif', ext: '.avif' }; } } return null; } // Upload image mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => { try { const body = await c.req.parseBody(); const file = body['file'] as File; if (!file) { return c.json({ error: 'No file provided' }, 400); } // Validate file size (cheap check before reading the whole buffer) if (file.size > MAX_FILE_SIZE) { const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10; return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400); } // Read the bytes and validate the *content* (not the client-provided type/name) const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const detected = detectImageType(buffer); if (!detected) { return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400); } // Generate unique filename using the *detected* extension (ignore client filename) const id = generateId(); const filename = `${id}${detected.ext}`; // Persist via the storage backend (local disk or S3-compatible object store). const storage = getStorage(); await storage.put(filename, buffer, detected.mime); // Get related info from form data const relatedId = body['relatedId'] as string | undefined; const relatedType = body['relatedType'] as string | undefined; // Save to database const now = getNow(); const mediaRecord = { id, fileUrl: storage.publicUrl(filename), type: 'image' as const, relatedId: relatedId || null, relatedType: relatedType || null, createdAt: now, }; await (db as any).insert(media).values(mediaRecord); return c.json({ media: mediaRecord, url: mediaRecord.fileUrl, }, 201); } catch (error) { console.error('Upload error:', error); return c.json({ error: 'Failed to upload file' }, 500); } }); // Get media by ID mediaRouter.get('/:id', async (c) => { const id = c.req.param('id'); const mediaRecord = await dbGet( (db as any).select().from(media).where(eq((media as any).id, id)) ); if (!mediaRecord) { return c.json({ error: 'Media not found' }, 404); } return c.json({ media: mediaRecord }); }); // Delete media mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); const mediaRecord = await dbGet( (db as any).select().from(media).where(eq((media as any).id, id)) ); if (!mediaRecord) { return c.json({ error: 'Media not found' }, 404); } // Delete the underlying object from the storage backend. try { await getStorage().delete(keyFromUrl(mediaRecord.fileUrl)); } catch (error) { console.error('Failed to delete file:', error); } // Delete from database await (db as any).delete(media).where(eq((media as any).id, id)); return c.json({ message: 'Media deleted successfully' }); }); // List media (admin) mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const relatedType = c.req.query('relatedType'); const relatedId = c.req.query('relatedId'); const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500); const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); // Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle. const conditions: any[] = []; if (relatedType) conditions.push(eq((media as any).relatedType, relatedType)); if (relatedId) conditions.push(eq((media as any).relatedId, relatedId)); let query = (db as any).select().from(media); if (conditions.length > 0) { query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); } const result = await dbAll(query.limit(limit).offset(offset)); return c.json({ media: result }); }); export default mediaRouter;