Compare commits
4 Commits
de223baaa4
...
d2b7d21824
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b7d21824 | ||
|
|
90fc281e90 | ||
|
|
6ab398e687 | ||
|
|
1bd5c924b0 |
9
API.md
9
API.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* Makes what the server derives from a file dump's upload with ffmpeg: a
|
||||
* video's still and an image's scaled-down copy, which the thumbnails route
|
||||
* serves, and an audio file's waveform, which the waveform route serves. Each
|
||||
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
|
||||
* module gives, until dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them.
|
||||
* Makes what the server derives from an upload with ffmpeg: a video's still,
|
||||
* which the thumbnails route serves, an audio file's waveform, which the
|
||||
* waveform route serves, and the scaled-down copies of any image, which
|
||||
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
|
||||
* is made on its first request and kept under a name this module gives, a
|
||||
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
|
||||
* dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
|
||||
* takes what that leaves behind.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import { dirname, relative } from "node:path";
|
||||
import {
|
||||
DUMPS_DIR,
|
||||
SCALED_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
|
||||
import type { Job } from "./scheduler.ts";
|
||||
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
|
||||
@@ -48,7 +59,7 @@ const VIDEO_FORMATS =
|
||||
* Lists the formats ffmpeg may read an image as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}.
|
||||
*/
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif";
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an audio file as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
|
||||
@@ -57,35 +68,50 @@ const AUDIO_FORMATS =
|
||||
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
|
||||
|
||||
/**
|
||||
* Maps the image types that get a scaled-down preview to the preview's format.
|
||||
* A GIF is served as it is, to keep its animation.
|
||||
* Returns where the still and the waveform generated from dump `dumpId`'s
|
||||
* upload are kept.
|
||||
*/
|
||||
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/bmp": "jpg",
|
||||
// May carry transparency, which JPEG would fill with black.
|
||||
"image/png": "png",
|
||||
"image/webp": "png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps an image preview's width. It covers the widest feed card on a
|
||||
* high-density screen, at a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
|
||||
export function generatedPaths(dumpId: string): {
|
||||
still: string;
|
||||
preview: Record<"jpg" | "png", string>;
|
||||
waveform: string;
|
||||
} {
|
||||
const base = `${THUMBNAILS_DIR}/${dumpId}`;
|
||||
return {
|
||||
still: `${base}.jpg`,
|
||||
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
|
||||
waveform: `${base}.waveform.json`,
|
||||
};
|
||||
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
|
||||
* for what doesn't read it, as some mail clients don't.
|
||||
*/
|
||||
export type ScaledFormat = "webp" | "jpg" | "png";
|
||||
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
|
||||
|
||||
/**
|
||||
* Returns where the copy of the upload at `srcPath` scaled to `width` is
|
||||
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
|
||||
* `UPLOADS_DIR`.
|
||||
*/
|
||||
export function scaledImagePath(
|
||||
srcPath: string,
|
||||
width: ImageWidth,
|
||||
format: ScaledFormat = "webp",
|
||||
): string {
|
||||
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
|
||||
* One that is replaced needs no call: its copies are made again, being older
|
||||
* than it ({@link ensureGenerated}).
|
||||
*/
|
||||
export async function removeScaledImages(srcPath: string): Promise<void> {
|
||||
await Promise.all(
|
||||
IMAGE_WIDTHS.flatMap((width) =>
|
||||
SCALED_FORMATS.map((format) =>
|
||||
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): {
|
||||
* has replaced or deleted it. The routes make them again from a new one.
|
||||
*/
|
||||
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
|
||||
const { still, preview, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all(
|
||||
[still, preview.jpg, preview.png, waveform].map((path) =>
|
||||
Deno.remove(path).catch(() => {})
|
||||
),
|
||||
);
|
||||
const { still, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all([
|
||||
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
|
||||
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +134,7 @@ async function writeAtomically(
|
||||
outPath: string,
|
||||
write: (tmpPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
await Deno.mkdir(dirname(outPath), { recursive: true });
|
||||
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await write(tmpPath);
|
||||
@@ -156,25 +181,87 @@ export function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
||||
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
/**
|
||||
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
|
||||
* A browser applies the orientation to the upload itself; the copy carries no
|
||||
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
|
||||
* only its recent versions apply to images.
|
||||
*/
|
||||
const ORIENTATION_FILTERS: Record<number, string> = {
|
||||
2: "hflip",
|
||||
3: "hflip,vflip",
|
||||
4: "vflip",
|
||||
5: "transpose=0",
|
||||
6: "transpose=1",
|
||||
7: "transpose=3",
|
||||
8: "transpose=2",
|
||||
};
|
||||
|
||||
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
|
||||
const ENCODERS: Record<ScaledFormat, string[]> = {
|
||||
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
|
||||
// A still: the first frame of an animation.
|
||||
jpg: ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
|
||||
png: ["-frames:v", "1", "-c:v", "png", "-f", "image2"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a copy of an image at most `width` wide, never wider than the image
|
||||
* itself, as a WebP unless `format` names another. WebP keeps a PNG's
|
||||
* transparency and a GIF's animation, at a fraction of either's bytes.
|
||||
* `orientation` is the EXIF orientation of a JPEG, as `scalableImage`
|
||||
* (lib/upload.ts) reads it.
|
||||
*/
|
||||
export function scaleImage(
|
||||
srcPath: string,
|
||||
outPath: string,
|
||||
width: ImageWidth,
|
||||
orientation = 1,
|
||||
format: ScaledFormat = "webp",
|
||||
): Promise<void> {
|
||||
const upright = ORIENTATION_FILTERS[orientation];
|
||||
// A JPEG's colour planes halve its height, which they need even.
|
||||
const height = format === "jpg" ? -2 : -1;
|
||||
return runFfmpeg([
|
||||
"-noautorotate",
|
||||
"-format_whitelist",
|
||||
IMAGE_FORMATS,
|
||||
"-i",
|
||||
srcPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
|
||||
"-f",
|
||||
"image2",
|
||||
...(outPath.endsWith(".png")
|
||||
? ["-c:v", "png"]
|
||||
: ["-c:v", "mjpeg", "-q:v", "4"]),
|
||||
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
|
||||
"-an",
|
||||
...ENCODERS[format],
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
|
||||
* does, or null when it can't be, because ffmpeg is missing or failed. The
|
||||
* image proxy scales what it fetched with it, and keeps the result itself.
|
||||
*/
|
||||
export async function scaleImageBytes(
|
||||
bytes: Uint8Array,
|
||||
width: ImageWidth,
|
||||
orientation = 1,
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!await ffmpegAvailable()) return null;
|
||||
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
|
||||
const outPath = `${srcPath}.webp`;
|
||||
try {
|
||||
await Deno.mkdir(dirname(srcPath), { recursive: true });
|
||||
await Deno.writeFile(srcPath, bytes);
|
||||
await scaleImage(srcPath, outPath, width, orientation);
|
||||
return await Deno.readFile(outPath);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample rate the audio is decoded at for its waveform. A bar stands for
|
||||
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
|
||||
@@ -269,20 +356,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Spares an ffmpeg run per request for an image it can't read: the
|
||||
* generations that failed, by output path, with the date of the source they
|
||||
* failed on. A new upload gets a new try.
|
||||
*/
|
||||
const failed = new Map<string, number>();
|
||||
|
||||
/** Returns the date `path` was last written, or null when it doesn't exist. */
|
||||
async function writtenAt(path: string): Promise<number | null> {
|
||||
try {
|
||||
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*
|
||||
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
|
||||
* and is made again when the two differ: an upload replaced in place, as an
|
||||
* avatar or a cover is, gets new copies without anyone deleting the old.
|
||||
* Without it, the caller deletes `outPath` when its source changes.
|
||||
*/
|
||||
export async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
srcPath?: string,
|
||||
): Promise<boolean> {
|
||||
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
|
||||
const made = await writtenAt(outPath);
|
||||
let version: number | null = null;
|
||||
if (srcPath) {
|
||||
version = await writtenAt(srcPath);
|
||||
if (version === null) return false;
|
||||
if (made === version) return true;
|
||||
if (failed.get(outPath) === version) return false;
|
||||
} else if (made !== null) {
|
||||
return true;
|
||||
}
|
||||
if (!await ffmpegAvailable()) return false;
|
||||
let pending = inFlight.get(outPath);
|
||||
if (!pending) {
|
||||
pending = generate().finally(() => inFlight.delete(outPath));
|
||||
pending = generate()
|
||||
.then(() =>
|
||||
version === null
|
||||
? undefined
|
||||
: Deno.utime(outPath, new Date(), new Date(version))
|
||||
)
|
||||
.catch((err) => {
|
||||
if (version !== null) failed.set(outPath, version);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => inFlight.delete(outPath));
|
||||
inFlight.set(outPath, pending);
|
||||
}
|
||||
return await pending.then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a temporary file must be for the sweep to take it: one being
|
||||
* written, by a generation under way, is seconds old, and one a crash left
|
||||
* is never finished.
|
||||
*/
|
||||
const ABANDONED_AFTER_MS = 60 * 60_000;
|
||||
|
||||
/** Yields the path of every file under `dir`, and nothing when it is missing. */
|
||||
async function* filesUnder(dir: string): AsyncGenerator<string> {
|
||||
let entries: Deno.DirEntry[];
|
||||
try {
|
||||
entries = await Array.fromAsync(Deno.readDir(dir));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* filesUnder(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the generated files nothing serves any more, and returns how many:
|
||||
* the scaled copies of an upload that is gone, which the services' own
|
||||
* removals missed or never made, as for a user's avatar; the previews of
|
||||
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
|
||||
* the scaled copies replaced; and the temporary files a generation left
|
||||
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
|
||||
* thumbnails kept there being uploads.
|
||||
*/
|
||||
export async function sweepGeneratedMedia(): Promise<number> {
|
||||
let removed = 0;
|
||||
const remove = (path: string) =>
|
||||
Deno.remove(path).then(() => {
|
||||
removed++;
|
||||
}, () => {});
|
||||
const abandoned = async (path: string) => {
|
||||
const at = await writtenAt(path);
|
||||
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
|
||||
};
|
||||
|
||||
for await (const path of filesUnder(THUMBNAILS_DIR)) {
|
||||
if (
|
||||
/\.preview\.(jpg|png)$/.test(path) ||
|
||||
(path.endsWith(".tmp") && await abandoned(path))
|
||||
) await remove(path);
|
||||
}
|
||||
|
||||
for await (const path of filesUnder(SCALED_DIR)) {
|
||||
// A copy is named after its upload's path: see scaledImagePath.
|
||||
const copy = relative(SCALED_DIR, path).match(
|
||||
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
|
||||
);
|
||||
const unused = copy
|
||||
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
|
||||
: await abandoned(path);
|
||||
if (unused) await remove(path);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
|
||||
* clears what a new version no longer uses as soon as it is deployed, then
|
||||
* once a day. `main.ts` starts it.
|
||||
*/
|
||||
export const generatedMediaSweepJob: Job = {
|
||||
name: "media-sweep",
|
||||
everyMs: 24 * 60 * 60_000,
|
||||
firstAfterMs: 60_000,
|
||||
async run() {
|
||||
const removed = await sweepGeneratedMedia();
|
||||
if (removed > 0) {
|
||||
console.log(`[media-sweep] removed ${removed} generated files unused`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
* Checks the images users upload and serves them back, scaled down to the
|
||||
* width a request names. The avatar, playlist cover, dump thumbnail and
|
||||
* attachment routes call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,6 +11,13 @@ import { APIException } from "./api-exception.ts";
|
||||
import { serveFile } from "./serve-file.ts";
|
||||
import { dirname } from "node:path";
|
||||
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
|
||||
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
|
||||
import {
|
||||
ensureGenerated,
|
||||
type ScaledFormat,
|
||||
scaledImagePath,
|
||||
scaleImage,
|
||||
} from "./ffmpeg.ts";
|
||||
|
||||
/**
|
||||
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
||||
@@ -62,10 +70,178 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
|
||||
* and only by the browser when `isPrivate`. It answers 404 and resolves false
|
||||
* when the file is missing. The caller checks first that the viewer may see
|
||||
* it.
|
||||
* Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its
|
||||
* bytes, and 1, upright, when it states none. A phone stores a photo as its
|
||||
* sensor saw it and says here how to turn it.
|
||||
*/
|
||||
function jpegOrientation(data: Uint8Array): number {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
// The segments follow the two-byte SOI, each a marker and its length.
|
||||
let at = 2;
|
||||
while (at + 4 <= data.length && data[at] === 0xFF) {
|
||||
const marker = data[at + 1];
|
||||
const length = view.getUint16(at + 2);
|
||||
// APP1 holding "Exif\0\0", then a TIFF header and its first directory.
|
||||
if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) {
|
||||
const tiff = at + 10;
|
||||
if (tiff + 8 > data.length) return 1;
|
||||
const little = view.getUint16(tiff) === 0x4949;
|
||||
const ifd = tiff + view.getUint32(tiff + 4, little);
|
||||
if (ifd + 2 > data.length) return 1;
|
||||
const entries = view.getUint16(ifd, little);
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const entry = ifd + 2 + i * 12;
|
||||
if (entry + 12 > data.length) return 1;
|
||||
if (view.getUint16(entry, little) === 0x0112) {
|
||||
const value = view.getUint16(entry + 8, little);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// The image data starts: no EXIF came before it.
|
||||
if (marker === 0xDA) return 1;
|
||||
at += 2 + length;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a file {@link scalableImage} needs: the EXIF segment, which
|
||||
* comes first in a JPEG, is 64 KB at most.
|
||||
*/
|
||||
const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
|
||||
|
||||
/** Returned by {@link scalableImage}, for `scaleImage`. */
|
||||
export interface ScalableImage {
|
||||
/** The EXIF orientation to turn the image by, 1 when it is upright. */
|
||||
orientation: number;
|
||||
/**
|
||||
* The format of its copy for a reader without WebP: a JPEG, or a PNG for
|
||||
* an image that may be transparent, which a JPEG would fill with black.
|
||||
*/
|
||||
compat: "jpg" | "png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are
|
||||
* `head`, or null when it is to be served as it is: an animated WebP, which
|
||||
* ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP.
|
||||
*/
|
||||
export function scalableImage(head: Uint8Array): ScalableImage | null {
|
||||
const mime = detectImageMime(head);
|
||||
// A file dump may be a BMP ("BM"), which no upload route here accepts.
|
||||
if (!mime) {
|
||||
return head[0] === 0x42 && head[1] === 0x4D
|
||||
? { orientation: 1, compat: "jpg" }
|
||||
: null;
|
||||
}
|
||||
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
|
||||
const animated = mime === "image/webp" && head[15] === 0x58 &&
|
||||
(head[20] & 0x02) !== 0;
|
||||
if (animated) return null;
|
||||
return mime === "image/jpeg"
|
||||
? { orientation: jpegOrientation(head), compat: "jpg" }
|
||||
: { orientation: 1, compat: "png" };
|
||||
}
|
||||
|
||||
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
|
||||
async function readHead(path: string): Promise<Uint8Array | null> {
|
||||
let file: Deno.FsFile;
|
||||
try {
|
||||
file = await Deno.open(path, { read: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const head = new Uint8Array(IMAGE_HEAD_BYTES);
|
||||
let filled = 0;
|
||||
while (filled < head.length) {
|
||||
const n = await file.read(head.subarray(filled));
|
||||
if (n === null) break;
|
||||
filled += n;
|
||||
}
|
||||
return head.subarray(0, filled);
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The type each format of a scaled copy is served as. */
|
||||
const SCALED_MIMES: Record<ScaledFormat, string> = {
|
||||
webp: "image/webp",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the copy of the image at `path` scaled to `width`, made now unless
|
||||
* it was already, as its path and type, or null when there is none to serve:
|
||||
* the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or
|
||||
* failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader
|
||||
* without WebP takes.
|
||||
*/
|
||||
async function scaledCopy(
|
||||
path: string,
|
||||
width: ImageWidth,
|
||||
compat: boolean,
|
||||
): Promise<{ path: string; mime: string } | null> {
|
||||
// The other format depends on the image, so it is read before the copy is
|
||||
// looked for; a WebP's path is known without.
|
||||
let how = compat ? await describe(path) : undefined;
|
||||
if (how === null) return null;
|
||||
const format: ScaledFormat = how ? how.compat : "webp";
|
||||
const scaledPath = scaledImagePath(path, width, format);
|
||||
const made = await ensureGenerated(scaledPath, async () => {
|
||||
how ??= await describe(path);
|
||||
if (!how) throw new Error("Not an image that can be scaled");
|
||||
await scaleImage(path, scaledPath, width, how.orientation, format);
|
||||
}, path);
|
||||
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
|
||||
}
|
||||
|
||||
/** Returns how to scale the image at `path`: see {@link scalableImage}. */
|
||||
async function describe(path: string): Promise<ScalableImage | null> {
|
||||
const head = await readHead(path);
|
||||
return head && scalableImage(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy
|
||||
* at most as wide as the request's `?w=` names (`shared/image.ts`), or as
|
||||
* `defaultWidth` without one, and the image as it is when the request names
|
||||
* no width or the copy can't be made. With `?f=compat`, the copy is a JPEG,
|
||||
* or a PNG for an image that may be transparent, which the digest's emails
|
||||
* ask for, since not every mail client reads WebP. Every route that answers
|
||||
* one of the site's own images ends here, so each takes both. It resolves
|
||||
* false, having sent nothing, when the file is missing.
|
||||
*/
|
||||
export async function serveImage(
|
||||
ctx: Context,
|
||||
path: string,
|
||||
mime: string,
|
||||
cacheControl: string,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ??
|
||||
defaultWidth;
|
||||
const compat = ctx.request.url.searchParams.get("f") === "compat";
|
||||
const scaled = width ? await scaledCopy(path, width, compat) : null;
|
||||
if (
|
||||
scaled &&
|
||||
await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl })
|
||||
) return true;
|
||||
return await serveFile(ctx, path, { mime, cacheControl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded image inline as `mime`, or scaled down as
|
||||
* {@link serveImage} does, cached for `cacheMaxAge` seconds, and only by the
|
||||
* browser when `isPrivate`. It answers 404 and resolves false when the file
|
||||
* is missing. The caller checks first that the viewer may see it.
|
||||
*/
|
||||
export async function serveUploadedFile(
|
||||
ctx: Context,
|
||||
@@ -73,16 +249,18 @@ export async function serveUploadedFile(
|
||||
mime: string,
|
||||
cacheMaxAge = 3600,
|
||||
isPrivate = false,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
ctx.response.headers.set("Content-Disposition", "inline");
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes, and a frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
// A frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
const served = await serveImage(
|
||||
ctx,
|
||||
filePath,
|
||||
mime,
|
||||
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
});
|
||||
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
defaultWidth,
|
||||
);
|
||||
if (!served) ctx.response.status = 404;
|
||||
return served;
|
||||
}
|
||||
|
||||
@@ -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
108
api/middleware/compress.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
62
api/test/lib/ffmpeg.test.ts
Normal file
62
api/test/lib/ffmpeg.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
90
api/test/middleware/compress.test.ts
Normal file
90
api/test/middleware/compress.test.ts
Normal 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));
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
@@ -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
42
shared/image.ts
Normal 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
22
shared/test/image.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,7 @@ export function PlaylistCardList(
|
||||
isOwner={!!user && user.id === p.userId}
|
||||
onDelete={onDelete && (() => onDelete(p))}
|
||||
privateBadgeOnly={privateBadgeOnly}
|
||||
grid={grid}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
44
src/config/imageSizes.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
87
src/test/utils/shared/earlyFetch.test.ts
Normal file
87
src/test/utils/shared/earlyFetch.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
95
src/utils/shared/earlyFetch.ts
Normal file
95
src/utils/shared/earlyFetch.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user