/** * Checks the images users upload and serves them back, scaled down to the * width a request names. 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"; import { type ImageWidth, parseImageWidth } from "../../shared/image.ts"; import { ensureGenerated, type ScaledFormat, scaledImagePath, scaleImage, } from "./ffmpeg.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; } /** * Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its * bytes, and 1, upright, when it states none. A phone stores a photo as its * sensor saw it and says here how to turn it. */ function jpegOrientation(data: Uint8Array): number { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); // The segments follow the two-byte SOI, each a marker and its length. let at = 2; while (at + 4 <= data.length && data[at] === 0xFF) { const marker = data[at + 1]; const length = view.getUint16(at + 2); // APP1 holding "Exif\0\0", then a TIFF header and its first directory. if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) { const tiff = at + 10; if (tiff + 8 > data.length) return 1; const little = view.getUint16(tiff) === 0x4949; const ifd = tiff + view.getUint32(tiff + 4, little); if (ifd + 2 > data.length) return 1; const entries = view.getUint16(ifd, little); for (let i = 0; i < entries; i++) { const entry = ifd + 2 + i * 12; if (entry + 12 > data.length) return 1; if (view.getUint16(entry, little) === 0x0112) { const value = view.getUint16(entry + 8, little); return value >= 1 && value <= 8 ? value : 1; } } return 1; } // The image data starts: no EXIF came before it. if (marker === 0xDA) return 1; at += 2 + length; } return 1; } /** * How much of a file {@link scalableImage} needs: the EXIF segment, which * comes first in a JPEG, is 64 KB at most. */ const IMAGE_HEAD_BYTES = 64 * 1024 + 32; /** Returned by {@link scalableImage}, for `scaleImage`. */ export interface ScalableImage { /** The EXIF orientation to turn the image by, 1 when it is upright. */ orientation: number; /** * The format of its copy for a reader without WebP: a JPEG, or a PNG for * an image that may be transparent, which a JPEG would fill with black. */ compat: "jpg" | "png"; } /** * Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are * `head`, or null when it is to be served as it is: an animated WebP, which * ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP. */ export function scalableImage(head: Uint8Array): ScalableImage | null { const mime = detectImageMime(head); // A file dump may be a BMP ("BM"), which no upload route here accepts. if (!mime) { return head[0] === 0x42 && head[1] === 0x4D ? { orientation: 1, compat: "jpg" } : null; } // "RIFF....WEBPVP8X", whose flags then say whether frames follow. const animated = mime === "image/webp" && head[15] === 0x58 && (head[20] & 0x02) !== 0; if (animated) return null; return mime === "image/jpeg" ? { orientation: jpegOrientation(head), compat: "jpg" } : { orientation: 1, compat: "png" }; } /** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */ async function readHead(path: string): Promise { let file: Deno.FsFile; try { file = await Deno.open(path, { read: true }); } catch { return null; } try { const head = new Uint8Array(IMAGE_HEAD_BYTES); let filled = 0; while (filled < head.length) { const n = await file.read(head.subarray(filled)); if (n === null) break; filled += n; } return head.subarray(0, filled); } finally { file.close(); } } /** The type each format of a scaled copy is served as. */ const SCALED_MIMES: Record = { webp: "image/webp", jpg: "image/jpeg", png: "image/png", }; /** * Returns the copy of the image at `path` scaled to `width`, made now unless * it was already, as its path and type, or null when there is none to serve: * the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or * failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader * without WebP takes. */ async function scaledCopy( path: string, width: ImageWidth, compat: boolean, ): Promise<{ path: string; mime: string } | null> { // The other format depends on the image, so it is read before the copy is // looked for; a WebP's path is known without. let how = compat ? await describe(path) : undefined; if (how === null) return null; const format: ScaledFormat = how ? how.compat : "webp"; const scaledPath = scaledImagePath(path, width, format); const made = await ensureGenerated(scaledPath, async () => { how ??= await describe(path); if (!how) throw new Error("Not an image that can be scaled"); await scaleImage(path, scaledPath, width, how.orientation, format); }, path); return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null; } /** Returns how to scale the image at `path`: see {@link scalableImage}. */ async function describe(path: string): Promise { const head = await readHead(path); return head && scalableImage(head); } /** * Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy * at most as wide as the request's `?w=` names (`shared/image.ts`), or as * `defaultWidth` without one, and the image as it is when the request names * no width or the copy can't be made. With `?f=compat`, the copy is a JPEG, * or a PNG for an image that may be transparent, which the digest's emails * ask for, since not every mail client reads WebP. Every route that answers * one of the site's own images ends here, so each takes both. It resolves * false, having sent nothing, when the file is missing. */ export async function serveImage( ctx: Context, path: string, mime: string, cacheControl: string, defaultWidth?: ImageWidth, ): Promise { // The browser must take the checked type, never guess another from the // bytes. ctx.response.headers.set("X-Content-Type-Options", "nosniff"); const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ?? defaultWidth; const compat = ctx.request.url.searchParams.get("f") === "compat"; const scaled = width ? await scaledCopy(path, width, compat) : null; if ( scaled && await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl }) ) return true; return await serveFile(ctx, path, { mime, cacheControl }); } /** * Serves an uploaded image inline as `mime`, or scaled down as * {@link serveImage} does, 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, defaultWidth?: ImageWidth, ): Promise { ctx.response.headers.set("Content-Disposition", "inline"); // A frontend on another origin may still show the image. ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); const served = await serveImage( ctx, filePath, mime, `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`, defaultWidth, ); 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; } }