/** * Checks the images users upload and serves them back. The avatar, playlist * cover, dump thumbnail and attachment routes call it. * * @module */ import type { Context } from "@oak/oak"; import { APIException } from "./api-exception.ts"; import { serveFile } from "./serve-file.ts"; import { dirname } from "node:path"; import { MAX_IMAGE_SIZE_BYTES } from "../config.ts"; /** * Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic * bytes, or null for anything else. These four are the only image types * accepted anywhere. The type the browser declared is never * trusted, since the file is later served with the type found here. */ 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; } /** * Checks an uploaded image's size and content, and returns its MIME type, * which the route stores to serve the image with. * * @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or * isn't a JPEG, PNG, GIF or WebP image. */ export function validateImageUpload(data: Uint8Array): string { if (data.length > MAX_IMAGE_SIZE_BYTES) { throw new APIException( 400, `File too large (max ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)} MB)`, ); } const mime = detectImageMime(data); if (!mime) { throw new APIException( 400, "File content is not a recognised image (JPEG, PNG, GIF, WebP)", ); } return mime; } /** * Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds, * and only by the browser when `isPrivate`. It answers 404 and resolves false * when the file is missing. The caller checks first that the viewer may see * it. */ export async function serveUploadedFile( ctx: Context, filePath: string, mime: string, cacheMaxAge = 3600, isPrivate = false, ): Promise { ctx.response.headers.set("Content-Disposition", "inline"); // The browser must take the checked type, never guess another from the // bytes, and a frontend on another origin may still show the image. ctx.response.headers.set("X-Content-Type-Options", "nosniff"); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); const served = await serveFile(ctx, filePath, { mime, cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, }); if (!served) ctx.response.status = 404; return served; } /** * Writes `data` to `filePath`, replacing whatever is there only once the whole * file is written, so a failed write leaves the old file whole. The upload * routes call it before recording the new file, so that clients told of it * never fetch it early. */ export async function replaceUploadedFile( filePath: string, data: Uint8Array, ): Promise { const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; await Deno.mkdir(dirname(filePath), { recursive: true }); try { await Deno.writeFile(tmpPath, data); await Deno.rename(tmpPath, filePath); } catch (err) { await Deno.remove(tmpPath).catch(() => {}); throw err; } }