/** * Checking uploaded images and serving uploaded files back. * * @module */ import type { Context } from "@oak/oak"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "./api-exception.ts"; import { serveFile } from "./serve-file.ts"; import { ALLOWED_IMAGE_MIMES, ATTACHMENTS_DIR, AVATARS_DIR, DUMPS_DIR, MAX_IMAGE_SIZE_BYTES, PLAYLIST_IMAGES_DIR, UPLOADS_DIR, } from "../config.ts"; export { ALLOWED_IMAGE_MIMES, ATTACHMENTS_DIR, AVATARS_DIR, DUMPS_DIR, PLAYLIST_IMAGES_DIR, UPLOADS_DIR, }; /** * Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic * bytes, ignoring the type the browser declared; null for anything else. */ 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. * * @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( 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; } /** * Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds * (privately when `isPrivate`). Answers 404 and resolves false when the file * is missing. */ export async function serveUploadedFile( ctx: Context, filePath: string, mime: string, cacheMaxAge = 3600, isPrivate = false, ): Promise { ctx.response.headers.set("Content-Disposition", "inline"); 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; }