app/lib/media/upload-contract.mjs
161 lines and 10 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| isFormUpload | 38 to 40 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| uploadSuccessBody | 48 to 50 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| uploadErrorBody | 55 to 57 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| uploadMedia | 67 to 81 | 4 | 7% | WatchCRAP 16.8 | No data: no mutants in this code |
| <anonymous:73> | 73 to 73 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:88> | 88 to 88 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| validateUpload | 106 to 142 | 6 | 100% | WatchCRAP 6 | No data: no mutants in this code |
| beginsAsMarkup | 151 to 160 | 9 | 100% | WatchCRAP 9 | No data: no mutants in this code |
| uploadErrorSentence | 166 to 171 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| uploadRedirectTo | 178 to 183 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
Imports
It imports app/lib/error-message.mjs, app/lib/media/byte-size.mjs. Imported by app/components/admin/media-upload-actions.tsx, app/components/admin/post-editor.tsx, app/lib/carrel/media-adapter.server.ts, app/lib/media/upload.server.ts, app/lib/operator/upload-media.server.ts, app/routes/admin.media._index.tsx, app/routes/admin.media.upload.ts.
Source
// The form branch is selected ONLY by `intent=upload-form`, never by sniffing a header: a changed
// default header would silently hand the editors' `response.json()` an HTML page.
// Must stay loadable by node:test: never import anything reaching a binding, a `.server` module or React.
import { errorMessage } from "../error-message.mjs";
import { byteSize } from "./byte-size.mjs";
export const ALLOWED = new Map([
["image/webp", "webp"],
["image/png", "png"],
["image/jpeg", "jpg"],
["image/avif", "avif"],
["image/gif", "gif"],
]);
// NOT IN THE ALLOWLIST, and refused by name rather than as an unknown type: an SVG can carry script,
// and /media is this site's own origin. The /media CSP (workers/csp.mjs) is the second wall, for
// anything that reaches the bucket some other way.
export const SVG_TYPE = "image/svg+xml";
const SVG_REFUSED =
"SVG is not accepted: it can carry script, and /media is this site's own origin. " +
"Export it as PNG or WebP.";
// A hint, not a control: drag-drop or a scripted post ignores `accept`. The control is the server check.
export const ACCEPT_ATTRIBUTE = [...ALLOWED.keys()].join(",");
export const MAX_BYTES = 10 * 1024 * 1024;
export const UPLOAD_FORM_INTENT = "upload-form";
export const UPLOAD_RETURN_PATH = "/admin/media";
/**
* @param {unknown} intent the submitted `intent` field, whatever arrived
* @returns {boolean}
*/
export function isFormUpload(intent) {
return intent === UPLOAD_FORM_INTENT;
}
/**
* Both editors destructure `url`, and one inserts it into the markdown: a rename breaks inserts with no
* type error.
*
* @param {string} key
*/
export function uploadSuccessBody(key) {
return { url: `/media/${key}`, key };
}
/**
* @param {string} message
*/
export function uploadErrorBody(message) {
return { error: message };
}
/**
* The editors' upload: one file to the JSON branch. Never rejects, so a caller that `void`s it
* still shows every failure: the route's own refusal, the status when the body is not the
* route's JSON, or the network error.
*
* @param {File} file
* @returns {Promise<{ url: string } | { error: string }>}
*/
export async function uploadMedia(file) {
try {
const form = new FormData();
form.set("file", file);
const response = await fetch("/admin/media/upload", { method: "POST", body: form });
if (!response.ok) {
const detail = /** @type {{ error?: string }} */ (await response.json().catch(() => ({})));
return uploadErrorBody(detail.error ?? `Upload failed (${response.status}).`);
}
const { url } = /** @type {{ url: string }} */ (await response.json());
return { url };
} catch (error) {
return uploadErrorBody(`Upload failed: ${errorMessage(error)}`);
}
}
// A code in the URL, not the message, so the address bar never carries prose someone can edit.
const UPLOAD_ERRORS = {
"no-file": "No file was chosen.",
"unsupported-type":
`That file type is not one this endpoint accepts. Uploads here are images: ` +
`${[...ALLOWED.keys()].map((mime) => mime.split("/")[1]).join(", ")}.`,
"too-large": `That image is over the ${MAX_BYTES / (1024 * 1024)} MB limit.`,
"svg-refused": SVG_REFUSED,
"content-mismatch":
"That file begins as markup, so it is not the image type it was uploaded " +
"as. Markup is not accepted: export the image as PNG or WebP.",
"images-unavailable":
"The image could not be measured, because Cloudflare Images did not answer. " +
"Nothing was stored; try again.",
};
/**
* `no-file` is left to each caller, because its repair differs. Takes the BYTES because the type is a
* claim: SVG bytes declared `image/png` would escape the SVG-as-attachment rule and be served inline.
*
* @param {{ type: string, bytes: ArrayBuffer }} file what arrived, before any store
* @returns {{ code: "unsupported-type" | "svg-refused" | "too-large" | "content-mismatch", message: string, status: number } | null}
*/
export function validateUpload({ type, bytes }) {
const size = bytes.byteLength;
// Before the generic check, so the refusal says why instead of reading as an oversight.
if (type.toLowerCase().split(";")[0]?.trim() === SVG_TYPE) {
return { code: "svg-refused", message: SVG_REFUSED, status: 415 };
}
if (!ALLOWED.has(type)) {
return {
code: "unsupported-type",
message: `Unsupported image type "${type || "unknown"}".`,
status: 415,
};
}
if (size > MAX_BYTES) {
return {
code: "too-large",
message: `Image is ${byteSize(size)}, over the ${MAX_BYTES / (1024 * 1024)} MB limit.`,
status: 413,
};
}
// Last: the only check that reads the payload.
if (beginsAsMarkup(bytes)) {
return {
code: "content-mismatch",
message:
`Those bytes begin as markup and the declared type is "${type}". ` +
`Markup, SVG included, is not accepted: export the image as PNG or WebP.`,
status: 415,
};
}
return null;
}
/**
* One byte, `<`: an XML declaration, a doctype, a comment and `<svg` all start with it. The BOM skip
* matters: a file saved from a Windows editor carries one.
*
* @param {ArrayBuffer} bytes
* @returns {boolean}
*/
export function beginsAsMarkup(bytes) {
const view = new Uint8Array(bytes);
let at = 0;
if (view[0] === 0xef && view[1] === 0xbb && view[2] === 0xbf) at = 3;
// A leading NUL is not skipped: that would be UTF-16, which no allowed type is.
while (at < view.length && (view[at] === 0x20 || view[at] === 0x09 || view[at] === 0x0a || view[at] === 0x0d)) {
at += 1;
}
return view[at] === 0x3c;
}
/**
* @param {string | null} code
* @returns {string | null} the sentence, or null for a code nothing emits
*/
export function uploadErrorSentence(code) {
if (!code) return null;
return Object.prototype.hasOwnProperty.call(UPLOAD_ERRORS, code)
? UPLOAD_ERRORS[/** @type {keyof typeof UPLOAD_ERRORS} */ (code)]
: null;
}
/**
* Success carries the key, so a reload repeats a harmless read rather than a second upload.
*
* @param {{ ok: true, key: string } | { ok: false, code: string }} result
*/
export function uploadRedirectTo(result) {
const params = result.ok
? `uploaded=${encodeURIComponent(result.key)}`
: `upload-error=${encodeURIComponent(result.code)}`;
return `${UPLOAD_RETURN_PATH}?${params}`;
}