Skip to the content

Capsid menu

src/media.ts

85 lines and 16 functions in DrDustinEdwards/site-api, at commit d3b340f on 2026-10-24. This file on GitHub.

Functions

The functions of media.ts
FunctionLinesComplexityCoveredCRAPMutation
essence9 to 112100%OkCRAP 2Watch66.7%
readUpload19 to 49996%WatchCRAP 9Watch71.7%
<anonymous:36>36 to 361100%OkCRAP 1No data: no mutants in this code
ascii51 to 511100%OkCRAP 1Critical0%
<anonymous:58>58 to 581100%OkCRAP 1Ok83.3%
<anonymous:58>58 to 581100%OkCRAP 1No data: no mutants in this code
<anonymous:59>59 to 593100%OkCRAP 3Critical45.5%
<anonymous:60>60 to 602100%OkCRAP 2Critical33.3%
<anonymous:61>61 to 612100%OkCRAP 2Critical11.1%
<anonymous:62>62 to 6220%WatchCRAP 6Critical0%
<anonymous:63>63 to 6310%OkCRAP 2Critical0%
<anonymous:65>65 to 683100%OkCRAP 3Critical7.1%
<anonymous:77>77 to 781100%OkCRAP 1No data: changed since the last mutation run
<anonymous:79>79 to 7910%OkCRAP 2No data: changed since the last mutation run
checkUpload83 to 947100%WatchCRAP 7No data: changed since the last mutation run
<anonymous:84>84 to 841100%OkCRAP 1No data: changed since the last mutation run

Imports

It imports src/contract.ts. Imported by src/server/media.ts.

Source

// The upload checks the package makes before a site sees a byte (v0.2.0): the declared type must be
// one the site accepts, the body may not exceed the site's limit (read no further than that), and
// the bytes must look like the declared type where the type has a signature. The site's own checks
// (measuring an image, its storage) come after these, never instead of them.

import type { MediaUploadLimits } from "./contract.js";

/** The media type without parameters, lower case: "image/png; x=1" is "image/png". */
export function essence(contentType: string | null): string {
  return (contentType ?? "").split(";")[0]!.trim().toLowerCase();
}

type UploadRefusal = { code: "invalid" | "too-large"; message: string };

/**
 * Reads the body up to `limits.maxBytes` and stops: a declared or actual size over the limit is
 * refused without buffering the rest.
 */
export async function readUpload(
  request: Request,
  limits: MediaUploadLimits,
): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; refusal: UploadRefusal }> {
  const tooLarge: UploadRefusal = { code: "too-large", message: `The file is larger than this site accepts (${limits.maxBytes} bytes).` };
  const declared = Number(request.headers.get("content-length") ?? "0");
  if (declared > limits.maxBytes) return { ok: false, refusal: tooLarge };
  if (!request.body) return { ok: false, refusal: { code: "invalid", message: "The upload is empty." } };

  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > limits.maxBytes) {
      await reader.cancel().catch(() => {});
      return { ok: false, refusal: tooLarge };
    }
    chunks.push(value);
  }
  if (total === 0) return { ok: false, refusal: { code: "invalid", message: "The upload is empty." } };
  const bytes = new Uint8Array(total);
  let at = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, at);
    at += chunk.byteLength;
  }
  return { ok: true, bytes };
}

const ascii = (bytes: Uint8Array, from: number, length: number) => String.fromCharCode(...bytes.subarray(from, from + length));

/**
 * The signature each known type's bytes must carry. A type with no entry here is the site's to
 * check; the package cannot tell what it should look like.
 */
const SIGNATURES: Record<string, (b: Uint8Array) => boolean> = {
  "image/png": (b) => [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((v, i) => b[i] === v),
  "image/jpeg": (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  "image/gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
  "image/webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP",
  "image/avif": (b) => ascii(b, 4, 4) === "ftyp" && /avi[fs]/.test(ascii(b, 8, 24)),
  "application/pdf": (b) => ascii(b, 0, 5) === "%PDF-",
  // Text, so no magic number: no NUL byte in the head, and an <svg element near the start.
  "image/svg+xml": (b) => {
    const head = new TextDecoder().decode(b.subarray(0, 4096));
    return !head.includes("\u0000") && /^?\s*</.test(head) && /<svg[\s>]/i.test(head);
  },
};

/**
 * Content that runs when a site serves the file from its own origin: script, event handlers, embedded documents and
 * javascript: links in an SVG, and JavaScript actions in a PDF. Such a file is refused rather than cleaned, since the
 * package does not rewrite what an editor uploads.
 */
const ACTIVE: Record<string, (b: Uint8Array) => boolean> = {
  "image/svg+xml": (b) =>
    /<script\b|[\s/"']on[a-z]+\s*=|<foreignObject\b|<(iframe|embed|object)\b|javascript:|href\s*=\s*["']\s*&#/i.test(new TextDecoder().decode(b)),
  "application/pdf": (b) => /\/(JS|JavaScript)\b/.test(new TextDecoder().decode(b)),
};

/** Refuses a type the site does not accept, and bytes that are not what the type claims. */
export function checkUpload(contentType: string, bytes: Uint8Array | null, limits: MediaUploadLimits): UploadRefusal | null {
  const types = limits.types.map((t) => t.toLowerCase());
  if (!types.includes(contentType)) {
    return { code: "invalid", message: `This site does not accept ${contentType || "a file with no type"}. It accepts ${types.join(", ")}.` };
  }
  if (bytes) {
    const signed = SIGNATURES[contentType];
    if (signed && !signed(bytes)) return { code: "invalid", message: `The file is not a ${contentType}: its bytes do not match the type it was sent as.` };
    if (ACTIVE[contentType]?.(bytes)) return { code: "invalid", message: `The ${contentType} file holds script or an embedded page, which would run on the site. Export it without them.` };
  }
  return null;
}