Skip to the content

Capsid menu

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

The functions of upload-contract.mjs
FunctionLinesComplexityCoveredCRAPMutation
isFormUpload38 to 401100%OkCRAP 1No data: no mutants in this code
uploadSuccessBody48 to 501100%OkCRAP 1No data: no mutants in this code
uploadErrorBody55 to 571100%OkCRAP 1No data: no mutants in this code
uploadMedia67 to 8147%WatchCRAP 16.8No data: no mutants in this code
<anonymous:73>73 to 7310%OkCRAP 2No data: no mutants in this code
<anonymous:88>88 to 881100%OkCRAP 1No data: no mutants in this code
validateUpload106 to 1426100%WatchCRAP 6No data: no mutants in this code
beginsAsMarkup151 to 1609100%WatchCRAP 9No data: no mutants in this code
uploadErrorSentence166 to 1713100%OkCRAP 3No data: no mutants in this code
uploadRedirectTo178 to 1832100%OkCRAP 2No 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}`;
}