Compare commits

..

4 Commits

Author SHA1 Message Date
khannurien
d2b7d21824 v4: the page a visit lands on asks for its content while its code loads — a first load went one step after the other: the entry script, then the interface's messages, then the page's chunk and its twenty-odd dependencies, and only then the request for the feed. The built page now carries a script in its head that asks for the messages' chunk at once, beside the entry, picking the language as detectLocale does (preloadCatalog in vite.config.ts; the two must agree). And main.tsx sends the landing page's first request as the app boots (utils/shared/earlyFetch.ts): the feed's first page on / and its tabs, the dump on a dump's page, as the stored session. authFetch hands that response to the page that asks for the same URL, once, to the session it was sent as and within ten seconds, in place of a request of its own; past that the page asks again. feedApiUrl (utils/shared/urls.ts) is the feed's URL for both. A category's feed, a profile, a playlist and the other pages wait for their code as before. On a local build with 150 ms of latency added, the feed's request leaves at 0.69 s where it left at 1.70 s, and the first card shows at 1.59 s where it showed at 1.92 s.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00
khannurien
90fc281e90 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>
2026-10-10 19:01:36 +00:00
khannurien
6ab398e687 v4: the API's JSON and the page are sent compressed — a page of the feed went out as it was, twenty kilobytes where a fifth does: only the built frontend's files were compressed, at build time, and Deno's server leaves alone a response whose length Oak has set. compressMiddleware (api/middleware/compress.ts), installed after the error middleware and around everything that sets a body, sends an object or array, and a string whose type its route set, in brotli or gzip as the request's Accept-Encoding takes, a coding refused with q=0 not being taken, from a kilobyte up, the threshold the build compresses from. Brotli is at quality 5, the frontend's files, compressed once, staying at 11. Files, streams and bytes, a response already encoded, a HEAD and one marked no-transform go out as they are, and a compressible answer says Vary: Accept-Encoding whether compressed or not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:06 +00:00
khannurien
1bd5c924b0 v4: Dark Reader is kept off the pages, which lost every background under it — on a phone in dark mode with the extension on, the avatar menu, the cards and the tabs were transparent over Dark Reader's own page colour, in every style. The build gives each light-dark() a fallback for the browsers without it, two custom properties of which one is empty, and Dark Reader's rewrite of the stylesheet drops the empty one under a dark system theme: both halves of every colour then apply at once, which is no colour, so nothing paints a background or a border. index.html now carries <meta name="darkreader-lock">, which the extension obeys by generating nothing; the site has dark palettes of its own, following the system or the user's choice. The dev server never showed it, serving light-dark() as written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:21 +00:00
55 changed files with 1623 additions and 218 deletions

9
API.md
View File

@@ -105,6 +105,15 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
thumbnail, waveform, cover or attachment takes the owner's media token as
`?token=`.
An image the API serves, an avatar, a thumbnail, a cover, an attachment or a
remote image, takes `?w=` and answers a WebP copy scaled down to that width,
snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider
than the image; without it, the image as it was uploaded, a dump's thumbnail
alone being scaled to the widest by default. `?f=compat` asks for the copy as a
JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made
of goes out as it is. JSON answers of a kilobyte or more are compressed, in
brotli or gzip, for a client that accepts it.
### me
- `GET /api/me` — signed in. The caller's own user.

View File

@@ -199,7 +199,7 @@ api/
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/
gerbeur.db # SQLite database (not committed); <name>.db per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); <name>/ per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
src/ # React frontend (Vite)
config/ # API base URL, default feed tab, player mode, instance settings
pages/ # One component per route, nothing else

View File

@@ -120,6 +120,12 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
* thumbnails route generates from file dumps.
*/
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/**
* Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes
* as they are first asked for, each at its upload's path with its width. It
* can be emptied at any time: the copies are made again.
*/
export const SCALED_DIR = `${UPLOADS_DIR}/scaled`;
/**
* Caps the size of an image upload, in bytes, for avatars, covers, thumbnails

View File

@@ -11,6 +11,7 @@ import {
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
import { removeScaledImages } from "../lib/ffmpeg.ts";
import { hashPassword } from "../lib/password.ts";
/**
@@ -60,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
if (orphanedAttachments.length > 0) {
for (const { id } of orphanedAttachments) {
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`);
}
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
.run(attachmentCutoff);

View File

@@ -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`);
}
},
};

View File

@@ -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;
}

View File

@@ -3,7 +3,8 @@
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
* the users out of other instances if they may no longer sign in there
* (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* through the error middleware, the compression of what is answered, the OG
* tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
*
@@ -46,11 +47,13 @@ import {
PORT,
PUBLIC_URL,
} from "./config.ts";
import { compressMiddleware } from "./middleware/compress.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
import { startJobs } from "./lib/scheduler.ts";
import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { generatedMediaSweepJob } from "./lib/ffmpeg.ts";
import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
@@ -68,6 +71,7 @@ const app: Application = new Application();
const cors = oakCors({ origin: ALLOWED_ORIGINS });
app.use(errorMiddleware);
app.use(compressMiddleware);
app.use(ogMiddleware);
// oakCors calls next() without awaiting it when there is no origin, which
// corrupts the response in Oak's async chain. So it only sees requests from an
@@ -138,6 +142,7 @@ if (import.meta.main) {
pingJob,
typingExpiryJob,
rateLimitSweepJob,
generatedMediaSweepJob,
outboxJob,
]);
await app.listen({ hostname: LISTEN_HOST, port: PORT });

108
api/middleware/compress.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Compresses what the routes answer in JSON, and the HTML page, for the
* browsers that accept it: a page of the feed is some twenty kilobytes as it
* is, and a fifth of that compressed. main.ts installs it after the error
* middleware, around everything that sets a body. The built frontend's files
* aren't its concern: lib/static.ts sends the copies compressed at build time.
*
* @module
*/
import type { Context, Next } from "@oak/oak";
import { promisify } from "node:util";
import { brotliCompress, constants as zlib, gzip } from "node:zlib";
const brotli = promisify(brotliCompress);
const gz = promisify(gzip);
/**
* Below this, the bytes saved aren't worth the work; it is the threshold the
* build compresses the frontend's files from (vite.config.ts).
*/
const MIN_BYTES = 1024;
/**
* Brotli's quality for a response made on the spot. Past 5 it takes several
* times longer for a few percent; the frontend's files, compressed once, are
* at 11.
*/
const BROTLI_QUALITY = 5;
/**
* Returns the coding to answer with for an `Accept-Encoding` header, brotli
* before gzip, or null when the client takes neither. A coding refused with
* `q=0` isn't taken.
*/
function pickCoding(header: string | null): "br" | "gzip" | null {
if (!header) return null;
const accepted = new Set<string>();
for (const part of header.split(",")) {
const [name, ...params] = part.trim().toLowerCase().split(";");
const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1])
.find((v) => v !== undefined);
if (q === undefined || Number(q) > 0) accepted.add(name.trim());
}
return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null;
}
/**
* Returns the text of a body this middleware compresses, or null for any
* other: an object or array, which Oak would send as JSON, or a string whose
* type its route has set. Files, streams and bytes are left alone, as they
* are images, media and files compressed already.
*/
function textOf(ctx: Context): string | null {
const body = ctx.response.body;
if (typeof body === "string") {
// Oak guesses an untyped string's type from the string itself, which it
// couldn't from its compressed bytes.
return ctx.response.type || ctx.response.headers.has("Content-Type")
? body
: null;
}
if (body === null || typeof body !== "object") return null;
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && !Array.isArray(body)) return null;
ctx.response.type = "json";
return JSON.stringify(body);
}
/**
* Sends the response's JSON or text compressed, in brotli or gzip as the
* request accepts, when it is at least {@link MIN_BYTES} long. A response
* already encoded, a `HEAD`, and one marked `no-transform` go out as they are.
*/
export async function compressMiddleware(
ctx: Context,
next: Next,
): Promise<void> {
await next();
const headers = ctx.response.headers;
if (
ctx.request.method === "HEAD" || headers.has("Content-Encoding") ||
headers.get("Cache-Control")?.includes("no-transform")
) return;
const text = textOf(ctx);
if (text === null) return;
const raw = new TextEncoder().encode(text);
if (raw.length < MIN_BYTES) return;
// A cache must not hand the compressed copy to a client that can't read it.
headers.append("Vary", "Accept-Encoding");
const coding = pickCoding(ctx.request.headers.get("Accept-Encoding"));
if (!coding) return;
const compressed = coding === "br"
? await brotli(raw, {
params: {
[zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT,
[zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
},
})
: await gz(raw);
headers.set("Content-Encoding", coding);
ctx.response.body = new Uint8Array(compressed);
}

View File

@@ -3,12 +3,15 @@
* http, which an https page would block, and those behind hotlink protection.
* It fetches and caches the images `GET /api/remote/image` serves, which the
* frontend routes a thumbnail through when it is on http or when loading it
* directly failed.
* directly failed, and scales them down as the site's own are.
*
* @module
*/
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { scaleImageBytes } from "../lib/ffmpeg.ts";
import { scalableImage } from "../lib/upload.ts";
/**
* Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends
@@ -34,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
/**
* Proxied images, by URL, since every viewer of a feed asks for the same
* thumbnails. The least recently used go first, and the Map's order is that
* of use.
* Proxied images, by URL and by the width they were scaled to, since every
* viewer of a feed asks for the same thumbnails. The least recently used go
* first, and the Map's order is that of use.
*/
const imageCache = new Map<
string,
@@ -89,15 +92,34 @@ export function clearImageCache(): void {
/**
* Returns the image at `url`, from the cache or else from its host; null when
* the host answers with an error, or with anything but a raster image of 5 MB
* at most. The route
* checks `url` with `isValidHttpUrl` first.
* at most. With `width`, it comes as a WebP copy scaled down to it, or as it
* is when no copy can be made (`scalableImage`, and ffmpeg). The route checks
* `url` with `isValidHttpUrl` first.
*
* @throws {Error} When the host can't be reached, as
* {@link fetchWithTimeout} reports it.
*/
export async function fetchProxiedImage(
url: string,
width?: ImageWidth | null,
): Promise<ProxiedImage | null> {
if (!width) return await fetchOriginal(url);
const key = `${url}#w${width}`;
const cached = readImageCache(key);
if (cached) return cached;
const original = await fetchOriginal(url);
if (!original) return null;
const how = scalableImage(original.bytes);
const bytes = how &&
await scaleImageBytes(original.bytes, width, how.orientation);
if (!bytes) return original;
writeImageCache(key, bytes, "image/webp");
return { bytes, contentType: "image/webp" };
}
/** Returns the image at `url` as its host sends it: see above. */
async function fetchOriginal(url: string): Promise<ProxiedImage | null> {
const cached = readImageCache(url);
if (cached) return cached;

View File

@@ -4,9 +4,10 @@
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id.
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
* private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image.
* - `GET /api/attachments/:attachmentId?w=&token=` — public, but an image in
* a private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image, scaled down to `w`
* (`shared/image.ts`) when it names one.
*
* @module
*/

View File

@@ -1,14 +1,14 @@
/**
* Stores and serves the users' avatars. The profile page uploads the avatar,
* and the client's `Avatar` component loads it, with the user's `updatedAt` as
* a cache-busting version.
* and the client's `Avatar` component loads it at the size it shows, with the
* user's `updatedAt` as a cache-busting version.
*
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
* caller's user. 400 for an account from another instance, whose avatar
* comes from there. The instances the caller signed in to are told.
* - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
* one.
* - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled
* down to `w` (`shared/image.ts`) when it names one; 404 without one.
*
* @module
*/

View File

@@ -26,9 +26,9 @@
* or with `playlist:moderate`. Removes a dump.
* - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
* without one.
* - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, scaled
* down to `w` (`shared/image.ts`) when it names one, or 404s without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it.
*

View File

@@ -6,9 +6,9 @@
*
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
* and whether its page could be reached.
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
* through this site; 400 when it isn't one, 502 when its host can't be
* reached.
* - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image
* through this site, scaled down to `w` (`shared/image.ts`) when it names
* one; 400 when it isn't an image, 502 when its host can't be reached.
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
* page's tracks and their stream URLs, for the native player; `force=1`
* skips the server's cache.
@@ -19,6 +19,7 @@
*/
import { Router } from "@oak/oak";
import { parseImageWidth } from "../../shared/image.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
@@ -88,7 +89,10 @@ router.get("/image", async (ctx) => {
}
try {
const image = await fetchProxiedImage(url);
const image = await fetchProxiedImage(
url,
parseImageWidth(ctx.request.url.searchParams.get("w")),
);
if (!image) {
ctx.response.status = 400;
return;

View File

@@ -5,11 +5,12 @@
* from an upload are generated on first request by lib/ffmpeg.ts, which keeps
* them.
*
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
* - `GET /api/dumps/:dumpId/thumbnail?v=&w=&token=` — public; a private
* dump's thumbnail needs its poster's media `token`. Serves the dump's
* custom thumbnail, else one made from its upload: a still of a video, or
* its image. An image comes scaled down to `w` (`shared/image.ts`), or to
* the widest of those widths without one, and as it is when no copy can be
* made; 404 when there is neither.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
@@ -32,18 +33,14 @@ import {
import { serveFile } from "../lib/serve-file.ts";
import {
replaceUploadedFile,
serveImage,
serveUploadedFile,
validateImageUpload,
} from "../lib/upload.ts";
import { DUMPS_DIR } from "../config.ts";
import { MAX_IMAGE_WIDTH } from "../../shared/image.ts";
import { resolveMediaDump } from "../lib/dump-media.ts";
import {
ensureGenerated,
generatedPaths,
imagePreview,
PREVIEWABLE_IMAGES,
videoStill,
} from "../lib/ffmpeg.ts";
import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
@@ -69,6 +66,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
dump.thumbnailMime,
0,
dump.isPrivate,
MAX_IMAGE_WIDTH,
);
if (served) return;
}
@@ -92,29 +90,12 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
) return;
}
if (mime.startsWith("image/")) {
// A scaled-down copy for the feed; the original when there's none to make
// (a GIF, no ffmpeg, an image ffmpeg can't read).
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const ext = PREVIEWABLE_IMAGES[mime];
if (ext) {
const previewPath = generated.preview[ext];
if (
await ensureGenerated(
previewPath,
() => imagePreview(srcPath, previewPath),
) &&
await serveFile(ctx, previewPath, {
mime: ext === "png" ? "image/png" : "image/jpeg",
cacheControl: generatedCache,
})
) return;
}
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache })
) return;
}
// A scaled-down copy for the feed; the original when there's none to make
// (no ffmpeg, an image it can't read).
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH)
) return;
throw new APIException(404, "No thumbnail for this dump");
});

View File

@@ -25,6 +25,7 @@ import {
} from "../../shared/settings.ts";
import type { CommunityStats } from "../../shared/community.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { siteHost } from "../../shared/url.ts";
import { markdownToPlainText } from "../../shared/markdown.ts";
import {
@@ -101,11 +102,19 @@ function quoteOf(markdown: string | undefined | null): string | undefined {
: flat;
}
/**
* The width the emails' thumbnails are asked at: the widest an email shows
* one, on a dense screen.
*/
const EMAIL_IMAGE_WIDTH: ImageWidth = 640;
/**
* Returns the image a mail client can load for `dump`, which must be public:
* the thumbnails route for a custom thumbnail or an image or video upload,
* else the link preview's image, through the image proxy when it is plain
* http, which a mail client may refuse.
* scaled to the email's width as a JPEG or PNG (`f=compat`), since not every
* mail client reads the WebP a browser gets; else the link preview's image,
* through the image proxy when it is plain http, which a mail client may
* refuse.
*/
function thumbnailOf(dump: Dump): string | null {
const isMedia = dump.kind === "file" &&
@@ -113,7 +122,7 @@ function thumbnailOf(dump: Dump): string | null {
if (dump.thumbnailMime || isMedia) {
return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${
dumpFileVersion(dump)
}`;
}&w=${EMAIL_IMAGE_WIDTH}&f=compat`;
}
const thumbnailUrl = dump.richContent?.thumbnailUrl;
if (typeof thumbnailUrl !== "string") return null;

View File

@@ -45,7 +45,7 @@ import {
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
import { removeGeneratedMedia, removeScaledImages } from "../lib/ffmpeg.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
@@ -854,6 +854,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
return updatedDump;
}
/** Deletes the file of dump `dumpId`'s custom thumbnail, and its copies. */
async function removeCustomThumbnail(dumpId: string): Promise<void> {
const path = customThumbnailPath(dumpId);
await Deno.remove(path).catch(() => {});
await removeScaledImages(path);
}
/**
* Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
@@ -864,7 +871,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
.run(dump.id);
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
await removeCustomThumbnail(dump.id);
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
announceDumpChange(dump, updatedDump);
return updatedDump;
@@ -891,9 +898,7 @@ export async function deleteDump(dumpId: string): Promise<void> {
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
await removeGeneratedMedia(dump.id);
}
if (dump.thumbnailMime) {
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
}
if (dump.thumbnailMime) await removeCustomThumbnail(dump.id);
announceDumpChange(dump, null, holders);
}

View File

@@ -388,6 +388,60 @@ export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
(c) => c.charCodeAt(0),
);
/** Whether ffmpeg runs here; the tests of what it makes are skipped without. */
export async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
/**
* Makes a real image with ffmpeg, a `width`×`height` field of `color`, as a
* PNG, or as a GIF of three frames.
*/
export async function makeImage(
width: number,
height: number,
color = "red",
format: "png" | "gif" = "png",
): Promise<Uint8Array<ArrayBuffer>> {
const { stdout } = await new Deno.Command("ffmpeg", {
args: [
"-f",
"lavfi",
"-i",
`color=c=${color}:size=${width}x${height}:rate=3`,
"-frames:v",
format === "gif" ? "3" : "1",
"-f",
format === "gif" ? "gif" : "image2pipe",
...(format === "gif" ? [] : ["-c:v", "png"]),
"-",
],
stdout: "piped",
stderr: "null",
}).output();
return new Uint8Array(stdout);
}
/** Returns the width, in pixels, a WebP file's header states. */
export function webpWidth(bytes: Uint8Array): number {
const kind = new TextDecoder().decode(bytes.subarray(12, 16));
// Lossy: the frame's start code, then 14 bits of width.
if (kind === "VP8 ") return (bytes[26] | (bytes[27] << 8)) & 0x3FFF;
// Lossless: a signature byte, then 14 bits of width minus one.
if (kind === "VP8L") return ((bytes[21] | (bytes[22] << 8)) & 0x3FFF) + 1;
// Extended, as an animation or an image with transparency: the canvas's
// width minus one, on 24 bits.
return (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1;
}
export function formWithFile(
bytes: Uint8Array<ArrayBuffer>,
name = "file.png",

View File

@@ -0,0 +1,62 @@
import { TEST_DIR } from "../setup.ts";
import { assertEquals } from "@std/assert";
import { sweepGeneratedMedia } from "../../lib/ffmpeg.ts";
const UPLOADS = `${TEST_DIR}/uploads`;
async function write(path: string, ageMs = 0): Promise<void> {
const full = `${UPLOADS}/${path}`;
await Deno.mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true });
await Deno.writeTextFile(full, "x");
if (ageMs > 0) {
const at = new Date(Date.now() - ageMs);
await Deno.utime(full, at, at);
}
}
const exists = (path: string) =>
Deno.stat(`${UPLOADS}/${path}`).then(() => true, () => false);
Deno.test("sweepGeneratedMedia: takes the copies of gone uploads, the old previews and abandoned temporary files", async () => {
const HOURS = 60 * 60_000;
const kept = [
// An upload and its copies, in each format.
"avatars/u1",
"scaled/avatars/u1.w128.webp",
"scaled/avatars/u1.w640.png",
// A custom thumbnail is an upload; a still and a waveform go with their
// dump, not here.
"thumbnails/d1-custom",
"scaled/thumbnails/d1-custom.w960.webp",
"thumbnails/d1.jpg",
"thumbnails/d1.waveform.json",
// Being written right now.
"scaled/avatars/u1.w320.webp.1234.tmp",
"scaled/tmp/fresh",
"thumbnails/d1.jpg.1234.tmp",
];
const swept = [
// Their upload is gone.
"scaled/avatars/u2.w128.webp",
"scaled/dumps/d2.w960.jpg",
// The name the previews of image dumps had.
"thumbnails/d1.preview.jpg",
"thumbnails/d3.preview.png",
];
const abandoned = [
"scaled/avatars/u1.w960.webp.5678.tmp",
"scaled/tmp/stale",
"scaled/tmp/stale.webp",
"thumbnails/d1.waveform.json.5678.tmp",
];
for (const path of [...kept, ...swept]) await write(path);
for (const path of abandoned) await write(path, 2 * HOURS);
assertEquals(await sweepGeneratedMedia(), swept.length + abandoned.length);
for (const path of kept) assertEquals(await exists(path), true, path);
for (const path of [...swept, ...abandoned]) {
assertEquals(await exists(path), false, path);
}
// Nothing left to take.
assertEquals(await sweepGeneratedMedia(), 0);
});

View File

@@ -1,7 +1,11 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import {
detectImageMime,
scalableImage,
validateImageUpload,
} from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
@@ -40,3 +44,67 @@ Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () =
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});
/** A JPEG's first bytes: SOI, then an EXIF segment stating `orientation`. */
function jpegWithOrientation(orientation: number, little: boolean): Uint8Array {
const u16 = (n: number) => little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff];
const u32 = (n: number) => little ? [...u16(n), 0, 0] : [0, 0, ...u16(n)];
const tiff = [
...(little ? [0x49, 0x49] : [0x4d, 0x4d]),
...u16(42),
...u32(8),
...u16(1),
...u16(0x0112),
...u16(3),
...u32(1),
...u16(orientation),
...[0, 0],
...u32(0),
];
const exif = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff];
const length = exif.length + 2;
return new Uint8Array([
...[0xff, 0xd8],
// A JFIF segment first, as a camera's file may have.
...[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00],
...[0xff, 0xe1, length >> 8, length & 0xff],
...exif,
...[0xff, 0xda],
]);
}
Deno.test("scalableImage: a JPEG's EXIF orientation, upright without one", () => {
const turn = (data: Uint8Array) => scalableImage(data)?.orientation;
assertEquals(turn(jpegWithOrientation(6, false)), 6);
assertEquals(turn(jpegWithOrientation(8, true)), 8);
assertEquals(turn(jpegWithOrientation(42, true)), 1);
assertEquals(turn(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2])), 1);
// Cut short of what it announces: upright, not an error.
assertEquals(turn(jpegWithOrientation(6, false).subarray(0, 20)), 1);
assertEquals(turn(PNG), 1);
});
Deno.test("scalableImage: an animated WebP and what isn't an image are served as they are", () => {
const webp = (flags: number) =>
new Uint8Array([
...new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8X"),
...[10, 0, 0, 0],
flags,
]);
assertEquals(scalableImage(webp(0x02)), null);
// A reader without WebP gets a PNG of what may be transparent, a JPEG of
// the rest.
assertEquals(scalableImage(webp(0x10)), { orientation: 1, compat: "png" });
assertEquals(
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
{ orientation: 1, compat: "png" },
);
assertEquals(scalableImage(PNG), { orientation: 1, compat: "png" });
assertEquals(scalableImage(jpegWithOrientation(1, true))?.compat, "jpg");
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
orientation: 1,
compat: "jpg",
});
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
assertEquals(scalableImage(new Uint8Array()), null);
});

View File

@@ -0,0 +1,90 @@
import { BASE_URL } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { Application } from "@oak/oak";
import { brotliDecompressSync, gunzipSync } from "node:zlib";
import { compressMiddleware } from "../../middleware/compress.ts";
const BIG = { success: true, data: { items: Array(200).fill("dump title") } };
function makeApp(): Application {
const app = new Application();
app.use(compressMiddleware);
app.use((ctx) => {
const path = ctx.request.url.pathname;
if (path === "/big") ctx.response.body = BIG;
if (path === "/small") ctx.response.body = { success: true };
if (path === "/html") {
ctx.response.type = "text/html";
ctx.response.body = `<!doctype html>${"<p>gerbeur</p>".repeat(200)}`;
}
if (path === "/bytes") ctx.response.body = new Uint8Array(4096);
if (path === "/encoded") {
ctx.response.headers.set("Content-Encoding", "br");
ctx.response.type = "text/css";
ctx.response.body = "x".repeat(4096);
}
});
return app;
}
async function get(path: string, acceptEncoding?: string) {
const res = await makeApp().handle(
new Request(`${BASE_URL}${path}`, {
headers: acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {},
}),
);
if (!res) throw new Error("no response");
return { res, bytes: new Uint8Array(await res.arrayBuffer()) };
}
const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
Deno.test("compressMiddleware: JSON goes out in brotli, or gzip for a client without it", async () => {
const br = await get("/big", "gzip, deflate, br");
assertEquals(br.res.headers.get("Content-Encoding"), "br");
assertEquals(
br.res.headers.get("Content-Type"),
"application/json; charset=UTF-8",
);
assertEquals(br.res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(brotliDecompressSync(br.bytes))), BIG);
const gzip = await get("/big", "gzip");
assertEquals(gzip.res.headers.get("Content-Encoding"), "gzip");
assertEquals(JSON.parse(text(gunzipSync(gzip.bytes))), BIG);
const refused = await get("/big", "br;q=0, gzip;q=0.5");
assertEquals(refused.res.headers.get("Content-Encoding"), "gzip");
});
Deno.test("compressMiddleware: a client that accepts no coding gets the JSON as it is", async () => {
for (const accept of [undefined, "identity", "br;q=0"]) {
const { res, bytes } = await get("/big", accept);
assertEquals(res.headers.get("Content-Encoding"), null);
// A cache still has to tell this answer from the compressed one.
assertEquals(res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(bytes)), BIG);
}
});
Deno.test("compressMiddleware: the HTML page is compressed too", async () => {
const { res, bytes } = await get("/html", "br");
assertEquals(res.headers.get("Content-Encoding"), "br");
assertEquals(res.headers.get("Content-Type"), "text/html; charset=UTF-8");
assertEquals(text(brotliDecompressSync(bytes)).startsWith("<!doctype"), true);
});
Deno.test("compressMiddleware: short answers, bytes and encoded files are left alone", async () => {
const small = await get("/small", "br");
assertEquals(small.res.headers.get("Content-Encoding"), null);
assertEquals(small.res.headers.get("Vary"), null);
assertEquals(JSON.parse(text(small.bytes)), { success: true });
const bytes = await get("/bytes", "br");
assertEquals(bytes.res.headers.get("Content-Encoding"), null);
assertEquals(bytes.bytes.length, 4096);
const encoded = await get("/encoded", "br");
assertEquals(encoded.res.headers.get("Content-Encoding"), "br");
assertEquals(text(encoded.bytes), "x".repeat(4096));
});

View File

@@ -1,4 +1,14 @@
import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts";
import {
createUser,
formWithFile,
get,
hasFfmpeg,
makeImage,
PNG,
put,
test,
webpWidth,
} from "../helpers.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { assertEquals, assertFalse } from "@std/assert";
@@ -27,3 +37,35 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
400,
);
});
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
const red = await makeImage(600, 400, "red");
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(red) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(small.headers.get("cache-control"), "public, max-age=3600");
assertEquals(webpWidth(small.bytes), 128);
// Never wider than the upload itself.
assertEquals(
webpWidth((await get(`/api/users/${user.id}/avatar?w=960`)).bytes),
600,
);
// Without a width, or with one that isn't a number, the upload goes out.
assertEquals((await get(`/api/users/${user.id}/avatar`)).bytes, red);
assertEquals((await get(`/api/users/${user.id}/avatar?w=big`)).bytes, red);
await put("/api/me/avatar", {
auth: user.auth,
form: formWithFile(await makeImage(600, 400, "blue")),
});
const again = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(webpWidth(again.bytes), 128);
assertFalse(
again.bytes.length === small.bytes.length &&
again.bytes.every((b, i) => b === small.bytes[i]),
);
});

View File

@@ -5,6 +5,7 @@ import {
del,
formWithFile,
get,
hasFfmpeg,
html,
insertCategory,
insertComment,
@@ -767,16 +768,27 @@ test("custom thumbnails: set, served, removed", async () => {
});
assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png");
// A scaled copy by default, as for an upload's own thumbnail; the image
// itself where no copy can be made.
const thumb = await get(`/api/dumps/${id}/thumbnail`);
assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
if (await hasFfmpeg()) {
assertEquals(thumb.headers.get("content-type"), "image/webp");
assertEquals(
readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null,
true,
);
} else {
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
}
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), null);
// A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404);
});

View File

@@ -1,4 +1,12 @@
import { get, html, PNG, stubNetwork } from "../helpers.ts";
import {
get,
hasFfmpeg,
html,
makeImage,
PNG,
stubNetwork,
webpWidth,
} from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts";
@@ -29,6 +37,25 @@ Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", asyn
assertEquals(svg.status, 400);
});
Deno.test("GET /api/remote/image: `?w=` scales the image, fetched once for every width", async () => {
if (!await hasFfmpeg()) return;
const wide = await makeImage(800, 200);
using net = stubNetwork(() =>
new Response(wide, { headers: { "content-type": "image/png" } })
);
const path = `/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/wide.png")
}`;
const small = await get(`${path}&w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertEquals(webpWidth((await get(`${path}&w=320`)).bytes), 320);
assertEquals((await get(`${path}&w=128`)).bytes, small.bytes);
assertEquals((await get(path)).bytes, wide);
assertEquals(net.calls.length, 1);
});
Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => {
let failing = true;
using net = stubNetwork(() =>

View File

@@ -1,34 +1,24 @@
import {
createUser,
db,
del,
get,
hasFfmpeg,
insertDump,
makeImage,
PNG,
test,
TEST_DIR,
webpWidth,
} from "../helpers.ts";
import { assertEquals } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789");
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
function readDir(path: string): string[] {
try {
return [...Deno.readDirSync(path)].map((e) => e.name);
@@ -81,54 +71,87 @@ test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy",
assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
});
test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => {
test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => {
const owner = await createUser();
const scaled = (id: string) =>
readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id))
.sort();
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({
const broken = insertDump({
userId: owner.id,
fileName: "a.gif",
fileMime: "image/gif",
fileData: gif,
});
const res = await get(`/api/dumps/${animated.id}/thumbnail`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif);
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/gif");
assertEquals(fallback.bytes, gif);
assertEquals(scaled(broken.id), []);
// A real PNG is scaled into a preview, kept beside the video stills.
if (!await hasFfmpeg()) {
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
});
const itself = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(itself.headers.get("content-type"), "image/png");
assertEquals(itself.bytes, PNG);
return;
}
// A real image is scaled to the widest width without `w`, and to the width
// asked for with it, each copy kept under the upload's own path.
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
fileData: await makeImage(1400, 300),
});
const preview = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) {
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(photo.id)
),
[`${photo.id}.preview.png`],
);
}
const widest = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(widest.status, 200);
assertEquals(widest.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(widest.bytes), 960);
const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
// A width between two of the list's takes the one above.
const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`);
assertEquals(webpWidth(between.bytes), 320);
assertEquals(scaled(photo.id), [
`${photo.id}.w128.webp`,
`${photo.id}.w320.webp`,
`${photo.id}.w960.webp`,
]);
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const broken = insertDump({
// A reader without WebP, as a mail client, asks for a PNG or a JPEG.
const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`);
assertEquals(compat.status, 200);
assertEquals(compat.headers.get("content-type"), "image/png");
// Its header states the width, on four bytes from the sixteenth.
assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640);
assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true);
// An animation keeps its frames.
const animated = insertDump({
userId: owner.id,
fileName: "b.png",
fileMime: "image/png",
fileData: BYTES,
fileName: "anim.gif",
fileMime: "image/gif",
fileData: await makeImage(400, 300, "blue", "gif"),
});
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);
const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`);
assertEquals(moving.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(moving.bytes), 128);
assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true);
// The copies go with the dump.
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(broken.id)
),
[],
(await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status,
200,
);
assertEquals(scaled(photo.id), []);
});

View File

@@ -9,6 +9,11 @@
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Keeps the Dark Reader extension off the page, which has dark palettes
of its own: it rewrites the stylesheet's colors, and its rewrite of
the fallback the build gives every light-dark() leaves them all
invalid under a dark system theme, so no background is painted. -->
<meta name="darkreader-lock" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />
<meta name="site-name" content="__SITE_NAME__" />

42
shared/image.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Names the widths the site's own images come in. Every image the API serves
* (avatars, covers, thumbnails, attachments, proxied images) takes `?w=` and
* answers a copy scaled down to it, so a card or an avatar doesn't load the
* upload at full size. The frontend builds those URLs from this list
* (`imageSrcSet` in `src/utils/shared/urls.ts`) and the server makes no other
* width (`serveImage` in `api/lib/upload.ts`), which bounds what it keeps.
*
* @module
*/
/**
* The widths, in pixels, smallest first: an avatar on a dense screen, a small
* cover, a card on a phone, and the widest feed card on a dense screen.
*/
export const IMAGE_WIDTHS = [128, 320, 640, 960] as const;
/** One of {@link IMAGE_WIDTHS}. */
export type ImageWidth = typeof IMAGE_WIDTHS[number];
/** The largest of {@link IMAGE_WIDTHS}, which a thumbnail never exceeds. */
export const MAX_IMAGE_WIDTH: ImageWidth =
IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
/**
* Returns the narrowest of {@link IMAGE_WIDTHS} that covers `width` pixels, or
* the widest when none does.
*/
export function snapImageWidth(width: number): ImageWidth {
return IMAGE_WIDTHS.find((w) => w >= width) ?? MAX_IMAGE_WIDTH;
}
/**
* Returns the width a `?w=` value asks for, snapped to {@link IMAGE_WIDTHS},
* or null when it is absent or not a positive number, which asks for the image
* as it is.
*/
export function parseImageWidth(raw: string | null): ImageWidth | null {
if (raw === null || !/^\d{1,5}$/.test(raw)) return null;
const width = Number(raw);
return width > 0 ? snapImageWidth(width) : null;
}

22
shared/test/image.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { assertEquals } from "@std/assert";
import { parseImageWidth, snapImageWidth } from "../image.ts";
Deno.test("snapImageWidth: the narrowest width that covers, the widest past them all", () => {
assertEquals(snapImageWidth(1), 128);
assertEquals(snapImageWidth(128), 128);
assertEquals(snapImageWidth(129), 320);
assertEquals(snapImageWidth(700), 960);
assertEquals(snapImageWidth(5000), 960);
});
Deno.test("parseImageWidth: a positive number, or no width at all", () => {
assertEquals(parseImageWidth("320"), 320);
assertEquals(parseImageWidth("200"), 320);
assertEquals(parseImageWidth(null), null);
assertEquals(parseImageWidth(""), null);
assertEquals(parseImageWidth("0"), null);
assertEquals(parseImageWidth("-64"), null);
assertEquals(parseImageWidth("12.5"), null);
assertEquals(parseImageWidth("wide"), null);
assertEquals(parseImageWidth("999999999"), null);
});

View File

@@ -12,7 +12,9 @@ import {
dumpFileUrl,
dumpThumbnailUrl,
dumpWaveformUrl,
imageProps,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import {
fileArtworkUrl,
@@ -72,6 +74,7 @@ function FilePlayButton({ dump }: { dump: Dump }) {
<Thumbnail
src={fileArtworkUrl(dump, token)}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{ seed: dump.fileName ?? dump.id, glyph: mimeIcon(mime) }}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -141,7 +144,10 @@ export function FilePreview(
// A card needs the server's scaled-down copy, not the full upload.
return (
<img
src={dumpThumbnailUrl(dump, token)}
{...imageProps(
dumpThumbnailUrl(dump, token),
IMAGE_SIZES.compactThumbnail,
)}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
loading="lazy"
@@ -158,6 +164,7 @@ export function FilePreview(
<Thumbnail
src={artworkUrl}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{
seed: dump.fileName ?? dump.id,
glyph: mimeIcon(mime),

View File

@@ -10,6 +10,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../../shared/dump.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
@@ -195,6 +196,7 @@ export const JournalCard: React.MemoExoticComponent<
<div className="journal-card-image">
<Thumbnail
src={thumbnailUrl}
sizes={IMAGE_SIZES.journal[shape]}
placeholder={dump.kind === "file"
// A video still fails without ffmpeg on the server, and the MIME
// glyph says more than an initial taken from the file name.

View File

@@ -14,6 +14,7 @@ import {
usePlayRichContent,
} from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
/**
* Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`,
@@ -68,6 +69,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -135,6 +137,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
sizes={IMAGE_SIZES.richThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-thumbnail"
/>

View File

@@ -17,6 +17,7 @@ import {
import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../../utils/player/duration.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
@@ -201,6 +202,7 @@ export function GlobalPlayer(): React.JSX.Element | null {
<Thumbnail
src={current.artworkUrl}
className="global-player-artwork"
sizes={IMAGE_SIZES.playerArtwork}
placeholder={{ siteName: current.subtitle }}
placeholderClassName="global-player-artwork"
loading="eager"

View File

@@ -10,7 +10,12 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import {
imageProps,
playlistImageUrl,
playlistUrl,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
import {
@@ -40,6 +45,8 @@ export interface PlaylistCardProps {
* public badges on every card there would say nothing.
*/
privateBadgeOnly?: boolean;
/** Set for a tile of the playlists page's grid, whose cover is larger. */
grid?: boolean;
}
/**
@@ -51,7 +58,7 @@ export interface PlaylistCardProps {
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
{ playlist, onDelete, isOwner, privateBadgeOnly, grid }: PlaylistCardProps,
): React.JSX.Element {
const { mediaToken } = useAuth();
const { cardProps } = useCardLink(
@@ -75,7 +82,10 @@ export function PlaylistCard(
{playlist.imageMime
? (
<img
src={playlistImageUrl(playlist, mediaToken)}
{...imageProps(
playlistImageUrl(playlist, mediaToken),
grid ? IMAGE_SIZES.playlistTile : IMAGE_SIZES.playlistThumb,
)}
alt=""
className="playlist-card-img"
loading="lazy"

View File

@@ -40,6 +40,7 @@ export function PlaylistCardList(
isOwner={!!user && user.id === p.userId}
onDelete={onDelete && (() => onDelete(p))}
privateBadgeOnly={privateBadgeOnly}
grid={grid}
/>
))}
</ul>

View File

@@ -9,7 +9,8 @@ import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
import { playlistImageUrl } from "../../utils/shared/urls.ts";
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx";
@@ -37,7 +38,10 @@ export function PlaylistHeader(
<>
{playlist.imageMime && (
<img
src={playlistImageUrl(playlist, mediaToken)}
{...imageProps(
playlistImageUrl(playlist, mediaToken),
IMAGE_SIZES.playlistBanner,
)}
alt=""
className="playlist-detail-img"
/>

View File

@@ -6,7 +6,7 @@
*/
import { useContext, useState } from "react";
import { userApiUrl } from "../../utils/shared/urls.ts";
import { imageProps, userApiUrl } from "../../utils/shared/urls.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
/**
@@ -25,8 +25,9 @@ export interface AvatarProps {
}
/**
* Renders the image from `GET /api/users/:userId/avatar`, or the user's initial
* when they have none or it fails to load. An avatar added, changed or removed
* Renders the image from `GET /api/users/:userId/avatar`, scaled by the server
* to the size it is shown at, or the user's initial when they have none or it
* fails to load. An avatar added, changed or removed
* since the caller's data was fetched shows at once, through the
* `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`.
*/
@@ -55,7 +56,7 @@ export function Avatar(
: userApiUrl({ id: userId }, "avatar");
return (
<img
src={src}
{...imageProps(src, size)}
alt={username}
title={username}
style={sizeStyle}

View File

@@ -7,6 +7,7 @@
import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { imageProps, isApiImageUrl } from "../../utils/shared/urls.ts";
import {
ThumbnailPlaceholder,
type ThumbnailPlaceholderProps,
@@ -23,6 +24,12 @@ export interface ThumbnailProps {
/** Applies to the image only. */
className?: string;
loading?: "lazy" | "eager";
/**
* The width the image is laid out at, as `imageProps` takes it. An image
* the API serves, the site's own or a proxied one, then loads no larger
* than that; another site's loads as that site sends it.
*/
sizes: number | string;
/** Shown when there's no `src` or it fails to load. */
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
placeholderClassName?: string;
@@ -42,6 +49,7 @@ export function Thumbnail(
alt = "",
className,
loading = "lazy",
sizes,
placeholder,
placeholderClassName,
}: ThumbnailProps,
@@ -64,11 +72,10 @@ export function Thumbnail(
);
}
const url = proxyImageUrl(src, { force: attempt === "proxied" });
return (
<img
src={attempt === "proxied"
? proxyImageUrl(src, { force: true })
: proxyImageUrl(src)}
{...(isApiImageUrl(url) ? imageProps(url, sizes) : { src: url })}
alt={alt}
className={className}
loading={loading}

44
src/config/imageSizes.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* States the width each of the site's images is laid out at, which App.css
* sets, so that `imageProps` asks the server for a copy no larger. A number
* is a fixed width in CSS pixels, a string a `sizes` attribute for a width
* that follows the viewport's. The components that show a thumbnail, a cover
* or a player's artwork take theirs from here; an avatar's is its `size`.
*
* @module
*/
import type { JournalShape } from "../utils/feed/journalLayout.ts";
/** A tile of the journal's grid spanning one of its columns. */
const JOURNAL_COLUMN =
"(max-width: 460px) 100vw, (max-width: 680px) 50vw, (max-width: 980px) 33vw, 290px";
/** A tile of the journal's grid spanning two of its columns. */
const JOURNAL_TWO_COLUMNS =
"(max-width: 680px) 100vw, (max-width: 980px) 66vw, 590px";
/** The width of each image, by the place it is shown. */
export const IMAGE_SIZES = {
/**
* A compact thumbnail at its widest, in a dump card's preview; the rows
* that show it smaller take the same copy.
*/
compactThumbnail: 128,
/** A link's thumbnail beside its text, above it on a narrow screen. */
richThumbnail: "(max-width: 512px) 100vw, 180px",
/** A journal tile's image, by the tile's shape. */
journal: {
feature: JOURNAL_TWO_COLUMNS,
wide: JOURNAL_TWO_COLUMNS,
tall: JOURNAL_COLUMN,
square: JOURNAL_COLUMN,
} satisfies Record<JournalShape, string>,
/** A playlist's cover in a row of a list. */
playlistThumb: 80,
/** A playlist's cover as a tile of the grid, two to a row when narrow. */
playlistTile: "(max-width: 512px) 50vw, 250px",
/** A playlist's cover as the banner of its page. */
playlistBanner: "(max-width: 860px) 100vw, 860px",
/** The artwork in the global player's header. */
playerArtwork: 36,
} as const;

View File

@@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
import type { UserSettings } from "../../../shared/settings.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
import {
forgetStoredSession,
isTokenExpired,
@@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult {
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
// The page a visit lands on finds its first request already sent, as the
// app booted (utils/shared/earlyFetch.ts).
const early = typeof input === "string" &&
(init.method ?? "GET") === "GET"
? takeEarly(input, token)
: undefined;
const res = await (early ?? fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
@@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult {
// The browser sets FormData's Content-Type, with its boundary.
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});
}));
if (res.status === 401) {
logout();

View File

@@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts";
/**
* Returns the stored locale, else French for a French browser, else English.
* The built page asks for that locale's messages ahead of this module, by the
* same rule (`preloadCatalog` in vite.config.ts): the two must agree.
*/
function detectLocale(): Locale {
const stored = localStorage.getItem("locale");

View File

@@ -1,6 +1,7 @@
/**
* Starts the frontend. It loads the stylesheets and the interface's language,
* then mounts `App` on index.html's `#root`.
* sends the first request of the page the visit lands on, then mounts `App`
* on index.html's `#root`.
*
* A deploy renames every chunk, so a tab opened before it fails to load the
* lazy chunks it hasn't fetched yet. It then reloads to pick up the new build,
@@ -17,6 +18,7 @@ import { I18nProvider } from "@lingui/react";
import { App } from "./App.tsx";
import { loadCatalog } from "./i18n.ts";
import { startLandingRequest } from "./utils/shared/earlyFetch.ts";
import "./fonts.css";
import "./tokens.css";
@@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => {
location.reload();
});
// The page this visit lands on is loaded with its route, and asks the API for
// its content once loaded: that request leaves now, with the page's code.
startLandingRequest(location.pathname);
// Before the first render, or it would show untranslated message ids.
await loadCatalog();

View File

@@ -14,8 +14,7 @@ import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
import { type FeedTab, type FollowView } from "../../shared/paths.ts";
import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL } from "../config/api.ts";
import { feedApiUrl } from "../utils/shared/urls.ts";
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
import type { Dump } from "../../shared/dump.ts";
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
@@ -84,9 +83,7 @@ export function Index(
const defaultTab = useViewerDefaultFeedTab();
const tab: FeedTab = feedTab ?? defaultTab;
const feedUrl = categorySlug
? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}`
: `${API_URL}/api/dumps`;
const feedUrl = feedApiUrl(categorySlug);
const fetchPage = useCallback(
(page: number, signal?: AbortSignal) =>
authFetch(pageUrl(feedUrl, page), { signal })

View File

@@ -35,7 +35,12 @@ describe("FilePreview", () => {
const d = file("image/png");
renderWithContexts(<FilePreview dump={d} compact />, { session });
const img = screen.getByRole("img", { name: "thing" });
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
// The copy scaled to a card's preview, on a dense screen.
expect(img).toHaveAttribute(
"src",
`${dumpThumbnailUrl(d, session.mediaToken)}&w=320`,
);
expect(img).toHaveAttribute("sizes", "128px");
expect(img).toHaveAttribute("loading", "lazy");
});
@@ -101,7 +106,7 @@ describe("FilePreview", () => {
);
expect(container.querySelector("button img")).toHaveAttribute(
"src",
dumpThumbnailUrl(d),
`${dumpThumbnailUrl(d)}&w=320`,
);
});

View File

@@ -123,8 +123,9 @@ describe("JournalCard", () => {
<JournalCard dump={dump} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe(
dumpThumbnailUrl(dump),
);
const img = container.querySelector("img")!;
expect(img.getAttribute("src")).toBe(`${dumpThumbnailUrl(dump)}&w=960`);
// A tile of one column: the browser takes a narrower copy for it.
expect(img.getAttribute("sizes")).toContain("290px");
});
});

View File

@@ -75,7 +75,7 @@ describe("RichContentCard", () => {
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
"http://localhost:8000/api/dumps/d/thumbnail",
"http://localhost:8000/api/dumps/d/thumbnail?w=960",
);
});

View File

@@ -5,14 +5,17 @@ import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContex
import { renderWithContexts } from "../../render.tsx";
describe("Avatar", () => {
test("an image when the user has one, versioned", () => {
test("an image when the user has one, versioned, at the size shown", () => {
renderWithContexts(
<Avatar userId="u1" username="ann" hasAvatar version={7} />,
<Avatar userId="u1" username="ann" hasAvatar version={7} size={32} />,
);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
const img = screen.getByRole("img", { name: "ann" });
expect(img).toHaveAttribute(
"src",
"http://localhost:8000/api/users/u1/avatar?v=7",
"http://localhost:8000/api/users/u1/avatar?v=7&w=128",
);
expect(img).toHaveAttribute("sizes", "32px");
expect(img.getAttribute("srcset")).toContain("avatar?v=7&w=320 320w");
});
test("initials otherwise, and after the image fails", () => {

View File

@@ -11,17 +11,20 @@ describe("Thumbnail", () => {
<Thumbnail
src="https://img.test/a.png"
alt="art"
sizes={128}
placeholder={placeholder}
/>,
);
const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
// Another site's image comes as that site sends it.
expect(img).not.toHaveAttribute("srcset");
fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
.toContain(
"/api/remote/image?url=",
);
const proxied = screen.getByRole("img", { name: "art" });
expect(proxied.getAttribute("src")).toContain("/api/remote/image?url=");
// Through the proxy, it is scaled as the site's own are.
expect(proxied.getAttribute("srcset")).toContain("a.png&w=128 128w");
fireEvent.error(screen.getByRole("img", { name: "art" }));
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
@@ -32,16 +35,24 @@ describe("Thumbnail", () => {
<Thumbnail
src="http://localhost:8000/api/dumps/d/thumbnail"
alt="t"
sizes="(max-width: 512px) 100vw, 180px"
placeholder={placeholder}
/>,
);
fireEvent.error(screen.getByRole("img", { name: "t" }));
const img = screen.getByRole("img", { name: "t" });
expect(img).toHaveAttribute(
"src",
"http://localhost:8000/api/dumps/d/thumbnail?w=960",
);
expect(img.getAttribute("srcset")).toContain("thumbnail?w=320 320w");
expect(img).toHaveAttribute("sizes", "(max-width: 512px) 100vw, 180px");
fireEvent.error(img);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
});
test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(
<Thumbnail placeholder={placeholder} alt="t" />,
<Thumbnail placeholder={placeholder} alt="t" sizes={128} />,
);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(
@@ -49,6 +60,7 @@ describe("Thumbnail", () => {
src="https://img.test/b.png"
placeholder={placeholder}
alt="t"
sizes={128}
/>,
);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();

View File

@@ -7,6 +7,9 @@ import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
import { calls, route } from "../../fetch.ts";
import { makeSession, makeToken, storeSession } from "../../fixtures.ts";
import { startLandingRequest } from "../../../utils/shared/earlyFetch.ts";
import { feedApiUrl } from "../../../utils/shared/urls.ts";
import { pageUrl } from "../../../utils/feed/pagedList.ts";
function wrapperFor(initial: Session | null) {
return function Wrapper({ children }: { children: ReactNode }) {
@@ -103,6 +106,19 @@ describe("authFetch", () => {
);
});
test("a GET the app started as it booted is handed over, not sent again", async () => {
route("GET /api/dumps", () => ({ items: ["early"], hasMore: false }));
startLandingRequest("/");
const feed = pageUrl(feedApiUrl(), 1);
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
const res = await result.current.authFetch(feed);
expect((await res.json()).data.items).toEqual(["early"]);
expect(calls("GET /api/dumps")).toHaveLength(1);
// Once: the next one is the page's own.
await result.current.authFetch(feed);
expect(calls("GET /api/dumps")).toHaveLength(2);
});
test("no Authorization header when signed out", async () => {
route("GET /api/open", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });

View File

@@ -126,7 +126,7 @@ describe("PlaylistDetail", () => {
await screen.findByRole("heading", { name: "Mix" });
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/cover?v=5000",
"http://localhost:8000/api/playlists/p1/cover?v=5000&w=960",
);
});
@@ -324,7 +324,7 @@ describe("PlaylistDetail", () => {
await waitFor(() =>
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/cover?v=9000",
"http://localhost:8000/api/playlists/p1/cover?v=9000&w=960",
)
);
});

View File

@@ -523,7 +523,7 @@ describe("UserPublicProfile", () => {
expect(document.querySelector(".profile-avatar-wrapper img"))
.toHaveAttribute(
"src",
"http://localhost:8000/api/users/ann/avatar?v=7000",
"http://localhost:8000/api/users/ann/avatar?v=7000&w=320",
)
);
});

View File

@@ -7,6 +7,7 @@ import { i18n } from "@lingui/core";
import { messages } from "../../locales/en.po";
import { FakeWebSocket } from "./ws.ts";
import { resetFetch } from "./fetch.ts";
import { clearEarlyRequests } from "../utils/shared/earlyFetch.ts";
// Lazy chunks and the app's own debounces (a link preview waits 600ms) make
// the default 1s too tight once every file runs in parallel.
@@ -179,6 +180,7 @@ document.execCommand ??= () => false;
beforeEach(() => {
resetFetch();
clearEarlyRequests();
FakeWebSocket.reset();
vi.stubGlobal("WebSocket", FakeWebSocket);
FakeIntersectionObserver.instances = [];

View File

@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
clearEarlyRequests,
landingRequest,
startLandingRequest,
takeEarly,
} from "../../../utils/shared/earlyFetch.ts";
import { API_URL } from "../../../config/api.ts";
import { feedApiUrl, userApiUrl } from "../../../utils/shared/urls.ts";
import { pageUrl } from "../../../utils/feed/pagedList.ts";
import { makeSession, storeSession } from "../../fixtures.ts";
describe("earlyFetch", () => {
const feed = pageUrl(feedApiUrl(), 1);
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn(() => Promise.resolve(new Response("{}")));
vi.stubGlobal("fetch", fetchMock);
localStorage.clear();
clearEarlyRequests();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
test("landingRequest names the feed's first page and a dump, as their pages do", () => {
expect(landingRequest("/")).toBe(feed);
expect(landingRequest("/hot")).toBe(feed);
expect(landingRequest("/journal/")).toBe(feed);
expect(landingRequest("/@ann/dumps/a-song")).toBe(
userApiUrl({ username: "ann" }, "dumps", "a-song"),
);
expect(landingRequest("/@ann/dumps/a-song")).toBe(
`${API_URL}/api/users/@ann/dumps/a-song`,
);
// A category or an app page, a profile and an editor wait for their code.
expect(landingRequest("/music")).toBeNull();
expect(landingRequest("/settings")).toBeNull();
expect(landingRequest("/follow/playlists")).toBeNull();
expect(landingRequest("/@ann")).toBeNull();
expect(landingRequest("/@ann/dumps/a-song/edit")).toBeNull();
expect(landingRequest("/@ann/playlists/mix")).toBeNull();
});
test("a request started is taken once, by the URL it was sent to", async () => {
startLandingRequest("/");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(feed);
expect(takeEarly(`${feed}&x=1`, undefined)).toBeUndefined();
const res = await takeEarly(feed, undefined);
expect(await res!.text()).toBe("{}");
expect(takeEarly(feed, undefined)).toBeUndefined();
});
test("a page that starts none sends nothing", () => {
startLandingRequest("/settings");
expect(fetchMock).not.toHaveBeenCalled();
});
test("it goes out as the stored session, and only that session takes it", () => {
const session = makeSession();
storeSession(session);
startLandingRequest("/");
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
`Bearer ${session.token}`,
);
// Signed out since: the answer held the user's private dumps.
expect(takeEarly(feed, undefined)).toBeUndefined();
startLandingRequest("/");
expect(takeEarly(feed, session.token)).toBeDefined();
});
test("an answer nobody took in time is dropped", () => {
vi.useFakeTimers();
startLandingRequest("/");
vi.advanceTimersByTime(11_000);
expect(takeEarly(feed, undefined)).toBeUndefined();
});
test("a request that fails rejects in the hands of whoever takes it", async () => {
fetchMock.mockImplementation(() => Promise.reject(new TypeError("down")));
startLandingRequest("/");
await expect(takeEarly(feed, undefined)).rejects.toThrow("down");
});
});

View File

@@ -3,6 +3,8 @@ import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
imageProps,
isApiImageUrl,
isAt,
normalizeUrl,
playlistImageUrl,
@@ -65,4 +67,27 @@ describe("urls", () => {
expect(playlistImageUrl({ id: "p", isPublic: false }, null))
.toBe(`${API_URL}/api/playlists/p/cover`);
});
test("imageProps asks for the copies the server makes, by the width shown", () => {
const avatar = `${API_URL}/api/users/u1/avatar`;
expect(imageProps(avatar, 32)).toEqual({
src: `${avatar}?w=128`,
srcSet: [128, 320, 640, 960].map((w) => `${avatar}?w=${w} ${w}w`)
.join(", "),
sizes: "32px",
});
// Twice the width shown, for a dense screen that reads only `src`.
expect(imageProps(avatar, 128).src).toBe(`${avatar}?w=320`);
// A URL with a query keeps it, and a width that follows the viewport's
// takes the widest copy as its `src`.
const cover = `${API_URL}/api/playlists/p1/cover?v=7`;
const fluid = imageProps(cover, "(max-width: 512px) 50vw, 250px");
expect(fluid.src).toBe(`${cover}&w=960`);
expect(fluid.srcSet).toContain(`${cover}&w=640 640w`);
expect(fluid.sizes).toBe("(max-width: 512px) 50vw, 250px");
});
test("isApiImageUrl tells the API's images from another site's", () => {
expect(isApiImageUrl(`${API_URL}/api/dumps/d1/thumbnail`)).toBe(true);
expect(isApiImageUrl(`${API_URL}/api/remote/image?url=x`)).toBe(true);
expect(isApiImageUrl("https://img.test/a.png")).toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
/**
* Starts the first request of the page a visit lands on before that page's
* code has loaded, so that the two travel together rather than one after the
* other. `main.tsx` starts it as the app boots ({@link startLandingRequest}),
* and `useAuth`'s `authFetch` hands its response to the page that asks for
* the same URL ({@link takeEarly}), in place of a request of its own.
*
* @module
*/
import { FEED_TABS, parsePagePath } from "../../../shared/paths.ts";
import { readStoredSession } from "../auth/storage.ts";
import { pageUrl } from "../feed/pagedList.ts";
import { feedApiUrl, userApiUrl } from "./urls.ts";
/**
* How long a response waits for its page. Past it, the page asks again: what
* was answered as the app booted isn't what a list holds a minute later.
*/
const MAX_AGE_MS = 10_000;
/** The requests started and not yet taken, by URL. */
const started = new Map<
string,
{ response: Promise<Response>; token: string | undefined; at: number }
>();
/**
* Returns the URL the page at `pathname` loads first, as that page builds it,
* or null for a page that isn't started early: the feed of every dump, on any
* of its tabs, asks for its first page, and a dump's page for the dump. The
* other pages wait for their code, as before.
*/
export function landingRequest(pathname: string): string | null {
const [first, ...more] = pathname.split("/").filter(Boolean);
if (
first === undefined ||
(more.length === 0 && (FEED_TABS as readonly string[]).includes(first))
) return pageUrl(feedApiUrl(), 1);
const page = parsePagePath(pathname);
if (page?.kind === "dump" && !page.edit) {
return userApiUrl({ username: page.username }, "dumps", page.slug);
}
return null;
}
/**
* Sends the request {@link landingRequest} names for `pathname`, with the
* stored session's token as `authFetch` would, and keeps its response for
* {@link takeEarly}. It never throws: a request that fails does so in the
* hands of the page that takes it, as its own would.
*/
export function startLandingRequest(pathname: string): void {
const url = landingRequest(pathname);
if (!url) return;
let token: string | undefined;
try {
token = readStoredSession()?.token;
} catch {
// Without storage there is no session, and the request goes as a guest's.
}
const response = fetch(url, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"Content-Type": "application/json",
},
});
// Nobody may take it: the failure is then nobody's to handle.
response.catch(() => {});
started.set(url, { response, token, at: Date.now() });
}
/**
* Returns the response of the request started for `url`, once and to the
* session it was sent as, or undefined when there is none, or none recent
* enough: the caller then sends its own. `authFetch` asks it for every GET.
*/
export function takeEarly(
url: string,
token: string | undefined,
): Promise<Response> | undefined {
const early = started.get(url);
if (!early) return undefined;
started.delete(url);
if (early.token !== token || Date.now() - early.at > MAX_AGE_MS) {
return undefined;
}
return early.response;
}
/** Forgets the requests started, between the tests' cases. */
export function clearEarlyRequests(): void {
started.clear();
}

View File

@@ -14,6 +14,7 @@ import {
playlistPath,
} from "../../../shared/paths.ts";
import { dumpFileVersion } from "../../../shared/dump.ts";
import { IMAGE_WIDTHS, snapImageWidth } from "../../../shared/image.ts";
/**
* Passed to {@link dumpFileUrl} and {@link dumpThumbnailUrl}, usually as a
@@ -86,6 +87,17 @@ function derivedMediaUrl(
}`;
}
/**
* Returns the URL of the feed's dumps, `GET /api/dumps`, every dump's or
* those of the category `categorySlug`; `pageUrl` adds the page to it. The
* index page loads it, and `landingRequest` names it to start it early.
*/
export function feedApiUrl(categorySlug?: string): string {
return categorySlug
? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}`
: `${API_URL}/api/dumps`;
}
/**
* Returns what a user typed as a link, trimmed, with `https://` added when it
* has no http(s) scheme. The result is not checked to be a valid URL.
@@ -166,3 +178,36 @@ export function playlistImageUrl(
params.length ? `?${params.join("&")}` : ""
}`;
}
/**
* Checks whether `url` is an image the API serves, its own or one it proxies,
* which all take `?w=` and answer a scaled-down copy. Another site's image,
* loaded from its own host, doesn't.
*/
export function isApiImageUrl(url: string): boolean {
return url.startsWith(`${API_URL}/api/`);
}
/**
* Returns what an `<img>` needs to load `url`, one of the API's images, no
* larger than it is shown: a `srcset` of the copies the server makes
* (`shared/image.ts`), from which the browser takes the narrowest that fills
* `sizes` on its screen, and a `src` for whatever reads only that. `sizes` is
* the width the image is laid out at, in CSS pixels, or a `sizes` attribute
* when that width follows the viewport's. `Avatar`, `Thumbnail` and the
* playlists' covers spread it on their image.
*/
export function imageProps(
url: string,
sizes: number | string,
): { src: string; srcSet: string; sizes: string } {
const at = (width: number) =>
`${url}${url.includes("?") ? "&" : "?"}w=${width}`;
return {
// Twice the laid-out width, as most screens want; the widest when that
// width isn't known here.
src: at(snapImageWidth(typeof sizes === "number" ? sizes * 2 : Infinity)),
srcSet: IMAGE_WIDTHS.map((width) => `${at(width)} ${width}w`).join(", "),
sizes: typeof sizes === "number" ? `${sizes}px` : sizes,
};
}

View File

@@ -104,6 +104,42 @@ function precompress(): Plugin {
};
}
// The interface's messages are a chunk of their own per language, which
// main.tsx imports before rendering anything (src/i18n.ts). Left to that
// import, the chunk is only asked for once the entry and the framework have
// loaded and run. This puts a script in the page's head that asks for it at
// once, beside them, picking the language as `detectLocale` does there: the
// two must agree, or the wrong language is fetched for nothing.
function preloadCatalog(): Plugin {
return {
name: "preload-catalog",
apply: "build",
transformIndexHtml: {
order: "post",
handler(_html, ctx) {
const catalogs: Record<string, string> = {};
for (const chunk of Object.values(ctx.bundle ?? {})) {
if (chunk.type !== "chunk") continue;
const locale = linguiConfig.locales.find((l) =>
chunk.moduleIds.some((id) => id.split("?")[0].endsWith(`/${l}.po`))
);
if (locale) catalogs[locale] = `/${chunk.fileName}`;
}
if (Object.keys(catalogs).length === 0) return [];
return [{
tag: "script",
injectTo: "head-prepend",
children: `(function(){var c=${JSON.stringify(catalogs)},l;` +
`try{l=localStorage.getItem("locale")}catch(e){}` +
`if(!c[l])l=navigator.language.indexOf("fr")===0?"fr":"en";` +
`if(!c[l])return;var k=document.createElement("link");` +
`k.rel="modulepreload";k.href=c[l];document.head.appendChild(k)})();`,
}];
},
},
};
}
// Each dev instance (dev/instances.ts) runs a Vite server of its own; sharing
// one dependency cache, two of them would rewrite it under each other.
const INSTANCE = process.env.GERBEUR_INSTANCE;
@@ -166,6 +202,7 @@ export default defineConfig({
},
},
emojibase(),
preloadCatalog(),
precompress(),
lingui(),
react({