Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
/**
|
|
* Checks the images users upload and serves them back. The avatar, playlist
|
|
* cover, dump thumbnail and attachment routes call it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { Context } from "@oak/oak";
|
|
import { APIException } from "./api-exception.ts";
|
|
import { serveFile } from "./serve-file.ts";
|
|
import { dirname } from "node:path";
|
|
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
|
|
|
|
/**
|
|
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
|
* bytes, or null for anything else. These four are the only image types
|
|
* accepted anywhere. The type the browser declared is never
|
|
* trusted, since the file is later served with the type found here.
|
|
*/
|
|
export function detectImageMime(data: Uint8Array): string | null {
|
|
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
|
return "image/jpeg";
|
|
}
|
|
if (
|
|
data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47
|
|
) return "image/png";
|
|
if (
|
|
data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38
|
|
) return "image/gif";
|
|
// RIFF....WEBP
|
|
if (
|
|
data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 &&
|
|
data[3] === 0x46 &&
|
|
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 &&
|
|
data[11] === 0x50
|
|
) return "image/webp";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Checks an uploaded image's size and content, and returns its MIME type,
|
|
* which the route stores to serve the image with.
|
|
*
|
|
* @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
|
|
* isn't a JPEG, PNG, GIF or WebP image.
|
|
*/
|
|
export function validateImageUpload(data: Uint8Array): string {
|
|
if (data.length > MAX_IMAGE_SIZE_BYTES) {
|
|
throw new APIException(
|
|
400,
|
|
`File too large (max ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)} MB)`,
|
|
);
|
|
}
|
|
const mime = detectImageMime(data);
|
|
if (!mime) {
|
|
throw new APIException(
|
|
400,
|
|
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
|
);
|
|
}
|
|
return mime;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function serveUploadedFile(
|
|
ctx: Context,
|
|
filePath: string,
|
|
mime: string,
|
|
cacheMaxAge = 3600,
|
|
isPrivate = false,
|
|
): 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");
|
|
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
const served = await serveFile(ctx, filePath, {
|
|
mime,
|
|
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
|
});
|
|
if (!served) ctx.response.status = 404;
|
|
return served;
|
|
}
|
|
|
|
/**
|
|
* Writes `data` to `filePath`, replacing whatever is there only once the whole
|
|
* file is written, so a failed write leaves the old file whole. The upload
|
|
* routes call it before recording the new file, so that clients told of it
|
|
* never fetch it early.
|
|
*/
|
|
export async function replaceUploadedFile(
|
|
filePath: string,
|
|
data: Uint8Array,
|
|
): Promise<void> {
|
|
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
await Deno.mkdir(dirname(filePath), { recursive: true });
|
|
try {
|
|
await Deno.writeFile(tmpPath, data);
|
|
await Deno.rename(tmpPath, filePath);
|
|
} catch (err) {
|
|
await Deno.remove(tmpPath).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|