import type { Context } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; export const UPLOADS_DIR = "api/uploads"; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB export const ALLOWED_IMAGE_MIMES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); /** Detect image MIME type from magic bytes, ignoring the browser-declared type. */ export function detectImageMime(data: Uint8Array): string | null { if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) { return "image/jpeg"; } if ( data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47 ) return "image/png"; if ( data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 ) return "image/gif"; // RIFF....WEBP if ( data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50 ) return "image/webp"; return null; } /** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */ export function validateImageUpload(data: Uint8Array): string { if (data.length > MAX_IMAGE_SIZE) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)", ); } const mime = detectImageMime(data); if (!mime) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File content is not a recognised image (JPEG, PNG, GIF, WebP)", ); } return mime; } export async function serveUploadedFile( ctx: Context, filePath: string, mime: string, cacheMaxAge = 3600, ): Promise { let data: Uint8Array; try { data = await Deno.readFile(filePath); } catch { ctx.response.status = 404; return false; } ctx.response.headers.set("Content-Type", mime); ctx.response.headers.set("Content-Disposition", "inline"); ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); ctx.response.body = data; return true; }