v4: every image the API serves comes scaled to the size it is shown at — a first visit to the feed loaded 4.8 MB of images out of 5.1 MB in all: an avatar shown at 32 pixels was the upload itself, a 1.4 MB GIF among them, and so were a cover, a custom thumbnail, an attachment and a proxied image, only an image dump having a preview, 960 pixels wide. Every route that answers an image now takes ?w= and sends a WebP copy at most that wide, of the widths shared/image.ts lists, 128, 320, 640 and 960, a width between two taking the one above, and never wider than the image; without it the upload goes out as before, a dump's thumbnail alone being scaled to the widest by default, the custom ones included. serveImage (lib/upload.ts) is where they all end, and the image proxy scales what it fetched the same way, keeping each width in its cache. lib/ffmpeg.ts makes the copies as it made the previews, which they replace: on first request, under uploads/scaled/ at the upload's own path, each given its upload's date and made again when the two differ, so a new avatar or cover needs nobody to delete the old ones. WebP keeps a PNG's transparency and a GIF's animation. A JPEG is turned by its EXIF orientation here, ffmpeg's own rotation of images depending on its version. An animated WebP, which ffmpeg can't read, and an image it fails on go out as they are, a failure being remembered until the upload changes. ?f=compat asks for the copy as a JPEG, or a PNG for what may be transparent: the digest's emails take their thumbnails that way, at 640 pixels, not every mail client reading WebP. On the client, imageProps (utils/shared/urls.ts) gives an image its srcset of those copies and the width it is laid out at, which config/imageSizes.ts states for each place from App.css: Avatar, Thumbnail, which scales an API image and leaves another site's as that site sends it, and the playlists' covers use it. The images of notes and comments still load as uploaded. The copies go with the dump, the custom thumbnail or the attachment they were made from, and generatedMediaSweepJob, a minute after the start then daily, takes those whose upload is gone, the previews under their former name and the temporary files a generation left unfinished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* Makes what the server derives from a file dump's upload with ffmpeg: a
|
||||
* video's still and an image's scaled-down copy, which the thumbnails route
|
||||
* serves, and an audio file's waveform, which the waveform route serves. Each
|
||||
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
|
||||
* module gives, until dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them.
|
||||
* Makes what the server derives from an upload with ffmpeg: a video's still,
|
||||
* which the thumbnails route serves, an audio file's waveform, which the
|
||||
* waveform route serves, and the scaled-down copies of any image, which
|
||||
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
|
||||
* is made on its first request and kept under a name this module gives, a
|
||||
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
|
||||
* dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
|
||||
* takes what that leaves behind.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import { dirname, relative } from "node:path";
|
||||
import {
|
||||
DUMPS_DIR,
|
||||
SCALED_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
|
||||
import type { Job } from "./scheduler.ts";
|
||||
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
|
||||
@@ -48,7 +59,7 @@ const VIDEO_FORMATS =
|
||||
* Lists the formats ffmpeg may read an image as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}.
|
||||
*/
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif";
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an audio file as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
|
||||
@@ -57,35 +68,50 @@ const AUDIO_FORMATS =
|
||||
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
|
||||
|
||||
/**
|
||||
* Maps the image types that get a scaled-down preview to the preview's format.
|
||||
* A GIF is served as it is, to keep its animation.
|
||||
* Returns where the still and the waveform generated from dump `dumpId`'s
|
||||
* upload are kept.
|
||||
*/
|
||||
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/bmp": "jpg",
|
||||
// May carry transparency, which JPEG would fill with black.
|
||||
"image/png": "png",
|
||||
"image/webp": "png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps an image preview's width. It covers the widest feed card on a
|
||||
* high-density screen, at a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
|
||||
export function generatedPaths(dumpId: string): {
|
||||
still: string;
|
||||
preview: Record<"jpg" | "png", string>;
|
||||
waveform: string;
|
||||
} {
|
||||
const base = `${THUMBNAILS_DIR}/${dumpId}`;
|
||||
return {
|
||||
still: `${base}.jpg`,
|
||||
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
|
||||
waveform: `${base}.waveform.json`,
|
||||
};
|
||||
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
|
||||
* for what doesn't read it, as some mail clients don't.
|
||||
*/
|
||||
export type ScaledFormat = "webp" | "jpg" | "png";
|
||||
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
|
||||
|
||||
/**
|
||||
* Returns where the copy of the upload at `srcPath` scaled to `width` is
|
||||
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
|
||||
* `UPLOADS_DIR`.
|
||||
*/
|
||||
export function scaledImagePath(
|
||||
srcPath: string,
|
||||
width: ImageWidth,
|
||||
format: ScaledFormat = "webp",
|
||||
): string {
|
||||
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
|
||||
* One that is replaced needs no call: its copies are made again, being older
|
||||
* than it ({@link ensureGenerated}).
|
||||
*/
|
||||
export async function removeScaledImages(srcPath: string): Promise<void> {
|
||||
await Promise.all(
|
||||
IMAGE_WIDTHS.flatMap((width) =>
|
||||
SCALED_FORMATS.map((format) =>
|
||||
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): {
|
||||
* has replaced or deleted it. The routes make them again from a new one.
|
||||
*/
|
||||
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
|
||||
const { still, preview, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all(
|
||||
[still, preview.jpg, preview.png, waveform].map((path) =>
|
||||
Deno.remove(path).catch(() => {})
|
||||
),
|
||||
);
|
||||
const { still, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all([
|
||||
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
|
||||
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +134,7 @@ async function writeAtomically(
|
||||
outPath: string,
|
||||
write: (tmpPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
await Deno.mkdir(dirname(outPath), { recursive: true });
|
||||
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await write(tmpPath);
|
||||
@@ -156,25 +181,87 @@ export function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
||||
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
/**
|
||||
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
|
||||
* A browser applies the orientation to the upload itself; the copy carries no
|
||||
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
|
||||
* only its recent versions apply to images.
|
||||
*/
|
||||
const ORIENTATION_FILTERS: Record<number, string> = {
|
||||
2: "hflip",
|
||||
3: "hflip,vflip",
|
||||
4: "vflip",
|
||||
5: "transpose=0",
|
||||
6: "transpose=1",
|
||||
7: "transpose=3",
|
||||
8: "transpose=2",
|
||||
};
|
||||
|
||||
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
|
||||
const ENCODERS: Record<ScaledFormat, string[]> = {
|
||||
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
|
||||
// A still: the first frame of an animation.
|
||||
jpg: ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
|
||||
png: ["-frames:v", "1", "-c:v", "png", "-f", "image2"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a copy of an image at most `width` wide, never wider than the image
|
||||
* itself, as a WebP unless `format` names another. WebP keeps a PNG's
|
||||
* transparency and a GIF's animation, at a fraction of either's bytes.
|
||||
* `orientation` is the EXIF orientation of a JPEG, as `scalableImage`
|
||||
* (lib/upload.ts) reads it.
|
||||
*/
|
||||
export function scaleImage(
|
||||
srcPath: string,
|
||||
outPath: string,
|
||||
width: ImageWidth,
|
||||
orientation = 1,
|
||||
format: ScaledFormat = "webp",
|
||||
): Promise<void> {
|
||||
const upright = ORIENTATION_FILTERS[orientation];
|
||||
// A JPEG's colour planes halve its height, which they need even.
|
||||
const height = format === "jpg" ? -2 : -1;
|
||||
return runFfmpeg([
|
||||
"-noautorotate",
|
||||
"-format_whitelist",
|
||||
IMAGE_FORMATS,
|
||||
"-i",
|
||||
srcPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
|
||||
"-f",
|
||||
"image2",
|
||||
...(outPath.endsWith(".png")
|
||||
? ["-c:v", "png"]
|
||||
: ["-c:v", "mjpeg", "-q:v", "4"]),
|
||||
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
|
||||
"-an",
|
||||
...ENCODERS[format],
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
|
||||
* does, or null when it can't be, because ffmpeg is missing or failed. The
|
||||
* image proxy scales what it fetched with it, and keeps the result itself.
|
||||
*/
|
||||
export async function scaleImageBytes(
|
||||
bytes: Uint8Array,
|
||||
width: ImageWidth,
|
||||
orientation = 1,
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!await ffmpegAvailable()) return null;
|
||||
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
|
||||
const outPath = `${srcPath}.webp`;
|
||||
try {
|
||||
await Deno.mkdir(dirname(srcPath), { recursive: true });
|
||||
await Deno.writeFile(srcPath, bytes);
|
||||
await scaleImage(srcPath, outPath, width, orientation);
|
||||
return await Deno.readFile(outPath);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample rate the audio is decoded at for its waveform. A bar stands for
|
||||
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
|
||||
@@ -269,20 +356,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Spares an ffmpeg run per request for an image it can't read: the
|
||||
* generations that failed, by output path, with the date of the source they
|
||||
* failed on. A new upload gets a new try.
|
||||
*/
|
||||
const failed = new Map<string, number>();
|
||||
|
||||
/** Returns the date `path` was last written, or null when it doesn't exist. */
|
||||
async function writtenAt(path: string): Promise<number | null> {
|
||||
try {
|
||||
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*
|
||||
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
|
||||
* and is made again when the two differ: an upload replaced in place, as an
|
||||
* avatar or a cover is, gets new copies without anyone deleting the old.
|
||||
* Without it, the caller deletes `outPath` when its source changes.
|
||||
*/
|
||||
export async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
srcPath?: string,
|
||||
): Promise<boolean> {
|
||||
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
|
||||
const made = await writtenAt(outPath);
|
||||
let version: number | null = null;
|
||||
if (srcPath) {
|
||||
version = await writtenAt(srcPath);
|
||||
if (version === null) return false;
|
||||
if (made === version) return true;
|
||||
if (failed.get(outPath) === version) return false;
|
||||
} else if (made !== null) {
|
||||
return true;
|
||||
}
|
||||
if (!await ffmpegAvailable()) return false;
|
||||
let pending = inFlight.get(outPath);
|
||||
if (!pending) {
|
||||
pending = generate().finally(() => inFlight.delete(outPath));
|
||||
pending = generate()
|
||||
.then(() =>
|
||||
version === null
|
||||
? undefined
|
||||
: Deno.utime(outPath, new Date(), new Date(version))
|
||||
)
|
||||
.catch((err) => {
|
||||
if (version !== null) failed.set(outPath, version);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => inFlight.delete(outPath));
|
||||
inFlight.set(outPath, pending);
|
||||
}
|
||||
return await pending.then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a temporary file must be for the sweep to take it: one being
|
||||
* written, by a generation under way, is seconds old, and one a crash left
|
||||
* is never finished.
|
||||
*/
|
||||
const ABANDONED_AFTER_MS = 60 * 60_000;
|
||||
|
||||
/** Yields the path of every file under `dir`, and nothing when it is missing. */
|
||||
async function* filesUnder(dir: string): AsyncGenerator<string> {
|
||||
let entries: Deno.DirEntry[];
|
||||
try {
|
||||
entries = await Array.fromAsync(Deno.readDir(dir));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* filesUnder(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the generated files nothing serves any more, and returns how many:
|
||||
* the scaled copies of an upload that is gone, which the services' own
|
||||
* removals missed or never made, as for a user's avatar; the previews of
|
||||
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
|
||||
* the scaled copies replaced; and the temporary files a generation left
|
||||
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
|
||||
* thumbnails kept there being uploads.
|
||||
*/
|
||||
export async function sweepGeneratedMedia(): Promise<number> {
|
||||
let removed = 0;
|
||||
const remove = (path: string) =>
|
||||
Deno.remove(path).then(() => {
|
||||
removed++;
|
||||
}, () => {});
|
||||
const abandoned = async (path: string) => {
|
||||
const at = await writtenAt(path);
|
||||
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
|
||||
};
|
||||
|
||||
for await (const path of filesUnder(THUMBNAILS_DIR)) {
|
||||
if (
|
||||
/\.preview\.(jpg|png)$/.test(path) ||
|
||||
(path.endsWith(".tmp") && await abandoned(path))
|
||||
) await remove(path);
|
||||
}
|
||||
|
||||
for await (const path of filesUnder(SCALED_DIR)) {
|
||||
// A copy is named after its upload's path: see scaledImagePath.
|
||||
const copy = relative(SCALED_DIR, path).match(
|
||||
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
|
||||
);
|
||||
const unused = copy
|
||||
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
|
||||
: await abandoned(path);
|
||||
if (unused) await remove(path);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
|
||||
* clears what a new version no longer uses as soon as it is deployed, then
|
||||
* once a day. `main.ts` starts it.
|
||||
*/
|
||||
export const generatedMediaSweepJob: Job = {
|
||||
name: "media-sweep",
|
||||
everyMs: 24 * 60 * 60_000,
|
||||
firstAfterMs: 60_000,
|
||||
async run() {
|
||||
const removed = await sweepGeneratedMedia();
|
||||
if (removed > 0) {
|
||||
console.log(`[media-sweep] removed ${removed} generated files unused`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
* 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
|
||||
*/
|
||||
@@ -10,6 +11,13 @@ 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
|
||||
@@ -62,10 +70,178 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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<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.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<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,
|
||||
@@ -73,16 +249,18 @@ export async function serveUploadedFile(
|
||||
mime: string,
|
||||
cacheMaxAge = 3600,
|
||||
isPrivate = false,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
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");
|
||||
// A frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
const served = await serveImage(
|
||||
ctx,
|
||||
filePath,
|
||||
mime,
|
||||
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
});
|
||||
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
defaultWidth,
|
||||
);
|
||||
if (!served) ctx.response.status = 404;
|
||||
return served;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user