287 lines
9.8 KiB
TypeScript
287 lines
9.8 KiB
TypeScript
/**
|
|
* 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 ImageTraits,
|
|
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 extends ImageTraits {
|
|
/**
|
|
* 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, animated: false, compat: "jpg" }
|
|
: null;
|
|
}
|
|
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
|
|
const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
|
|
(head[20] & 0x02) !== 0;
|
|
if (animatedWebp) return null;
|
|
return mime === "image/jpeg"
|
|
? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
|
|
: { orientation: 1, animated: mime === "image/gif", compat: "png" };
|
|
}
|
|
|
|
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
|
|
async function readHead(path: string): Promise<Uint8Array | null> {
|
|
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<ScaledFormat, string> = {
|
|
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, 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<ScalableImage | null> {
|
|
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<boolean> {
|
|
// 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<boolean> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|