Vectrum
Sign In
334 lines · 13.7 KB · TypeScript
import "server-only";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import {
  appendUpload,
  cancelUpload,
  deleteManifest,
  deleteTag,
  findBlob,
  findManifest,
  findUpload,
  finishUpload,
  isDigest,
  isImagePath,
  listReferrers,
  listTags,
  MAX_MANIFEST_BYTES,
  MEDIA_TYPES,
  mountBlob,
  putManifest,
  RegistryError,
  startUpload,
  TAG_RE,
  unlinkBlob,
  type Upload,
} from "./registry";
import { canWrite, findRepo, repoAccess, type RepoRecord } from "./repos";
import { basicAuthToken, tokenCovers } from "./tokens";

// The OCI distribution API (Docker Registry HTTP API v2) under /v2. Clients log in with HTTP Basic
// auth, an API token as the password, like git over HTTPS: `docker login <host> -u <you>`.

const API_HEADERS = { "Docker-Distribution-API-Version": "registry/2.0" };

function reply(status: number, headers: Record<string, string> = {}, body: BodyInit | null = null) {
  return new Response(body, { status, headers: { ...API_HEADERS, ...headers } });
}

function json(status: number, data: unknown, headers: Record<string, string> = {}) {
  return reply(status, { "Content-Type": "application/json", ...headers }, JSON.stringify(data));
}

function error(status: number, code: string, message: string, headers: Record<string, string> = {}) {
  return json(status, { errors: [{ code, message, detail: null }] }, headers);
}

const unauthorized = (message: string) =>
  error(401, "UNAUTHORIZED", message, { "WWW-Authenticate": 'Basic realm="Vectrum"' });

type Target = { repo: RepoRecord; image: string; name: string };
type Credentials = ReturnType<typeof basicAuthToken>;

/**
 * Resolves an image name (<owner>/<repo>[/<path>]) and checks that the request may pull from or
 * push to it. Anonymous users can pull images of public repositories; everything else needs a
 * token that covers the repository, and pushing needs write access to it.
 */
function authorize(creds: Credentials, name: string, need: "pull" | "push"): Target | Response {
  if (creds === "invalid") return unauthorized("Invalid or expired token.");
  const [owner, repoName, ...rest] = name.split("/");
  const image = rest.join("/");
  if (!repoName || !isImagePath(name)) return error(400, "NAME_INVALID", `Invalid image name ${name}. Names are lowercase.`);
  const repo = findRepo(owner, repoName);

  if (!creds) {
    if (repo && need === "pull" && repo.visibility === "public") return { repo, image, name };
    // Ask for credentials; don't reveal whether private repositories exist.
    return unauthorized("Authentication required. Log in with an API token as the password.");
  }

  const access = repo ? repoAccess(repo, creds.user.id) : null;
  if (!repo || !access) {
    return error(404, "NAME_UNKNOWN", `No repository ${owner}/${repoName}. Images are pushed to an existing repository.`);
  }
  const pushing = need === "push";
  if (pushing && creds.token.permission !== "write") {
    return error(403, "DENIED", "This token is read-only. Create a read & write token to push.");
  }
  if (!tokenCovers(creds.token, repo.id, pushing)) {
    return error(403, "DENIED", "This token does not grant access to this repository.");
  }
  if (pushing && !canWrite(access)) return error(403, "DENIED", `Permission to ${repo.owner}/${repo.name} denied.`);
  return { repo, image, name };
}

const ROUTES = [
  { kind: "uploads", re: /^\/(.+)\/blobs\/uploads\/?$/ },
  { kind: "upload", re: /^\/(.+)\/blobs\/uploads\/([^/]+)$/ },
  { kind: "blob", re: /^\/(.+)\/blobs\/([^/]+)$/ },
  { kind: "manifest", re: /^\/(.+)\/manifests\/([^/]+)$/ },
  { kind: "tags", re: /^\/(.+)\/tags\/list$/ },
  { kind: "referrers", re: /^\/(.+)\/referrers\/([^/]+)$/ },
] as const;

type Kind = (typeof ROUTES)[number]["kind"];

const PUSH_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const ALLOWED: Record<Kind, string[]> = {
  uploads: ["POST"],
  upload: ["GET", "PATCH", "PUT", "DELETE"],
  blob: ["GET", "HEAD", "DELETE"],
  manifest: ["GET", "HEAD", "PUT", "DELETE"],
  tags: ["GET"],
  referrers: ["GET"],
};

/** Handles every request under /v2. */
export async function handleRegistry(request: Request) {
  const url = new URL(request.url);
  const rest = url.pathname.replace(/^\/v2/, "");
  const creds = basicAuthToken(request);

  if (rest === "" || rest === "/") {
    if (request.method !== "GET" && request.method !== "HEAD") return error(405, "UNSUPPORTED", "Method not allowed.");
    // `docker login` checks its credentials here, so anonymous requests are always challenged.
    if (!creds) return unauthorized("Authentication required. Log in with an API token as the password.");
    if (creds === "invalid") return unauthorized("Invalid or expired token.");
    return json(200, {});
  }

  for (const { kind, re } of ROUTES) {
    const match = re.exec(rest);
    if (!match) continue;
    const [name, param = ""] = match.slice(1).map(decodeURIComponent);
    if (!ALLOWED[kind].includes(request.method)) return error(405, "UNSUPPORTED", "Method not allowed.");
    const target = authorize(creds, name, PUSH_METHODS.has(request.method) ? "push" : "pull");
    if (target instanceof Response) return target;
    try {
      switch (kind) {
        case "uploads":
          return await startBlobUpload(request, url, target, creds);
        case "upload":
          return await continueBlobUpload(request, url, target, param);
        case "blob":
          return await blob(request, target, param);
        case "manifest":
          return await manifest(request, target, param);
        case "tags":
          return tags(url, target);
        case "referrers":
          return referrers(url, target, param);
      }
    } catch (e) {
      if (e instanceof RegistryError) return error(e.status, e.code, e.message);
      throw e;
    }
  }
  return error(404, "NAME_UNKNOWN", "Not found.");
}

// ---------------------------------------------------------------------------
// Blobs

async function blob(request: Request, { repo }: Target, digest: string) {
  if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest.");
  if (request.method === "DELETE") {
    return unlinkBlob(repo.id, digest) ? reply(202) : error(404, "BLOB_UNKNOWN", "Blob not found.");
  }
  const found = findBlob(repo.id, digest);
  const size = found && (await stat(found.path).catch(() => null))?.size;
  if (!found || size == null) return error(404, "BLOB_UNKNOWN", "Blob not found.");

  const headers: Record<string, string> = {
    "Content-Type": "application/octet-stream",
    "Docker-Content-Digest": digest,
    "Accept-Ranges": "bytes",
    // Content-addressed, so it never changes.
    "Cache-Control": "public, max-age=31536000, immutable",
  };
  let start = 0;
  let end = size - 1;
  const range = request.headers.get("range");
  if (range) {
    const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
    if (m && (m[1] || m[2])) {
      start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
      end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
    }
    if (!m || start > end || start >= size) return reply(416, { ...headers, "Content-Range": `bytes */${size}` });
    headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
  }
  headers["Content-Length"] = String(end - start + 1);
  const status = range ? 206 : 200;
  if (request.method === "HEAD" || size === 0) return reply(status, headers);
  const stream = Readable.toWeb(createReadStream(found.path, { start, end })) as ReadableStream<Uint8Array>;
  return reply(status, headers, stream);
}

const uploadHeaders = ({ name }: Target, upload: Upload, size: number) => ({
  Location: `/v2/${name}/blobs/uploads/${upload.id}`,
  Range: `0-${Math.max(0, size - 1)}`,
  "Docker-Upload-UUID": upload.id,
});

const blobCreated = ({ name }: Target, digest: string) =>
  reply(201, { Location: `/v2/${name}/blobs/${digest}`, "Docker-Content-Digest": digest });

/** POST /v2/<name>/blobs/uploads/: mounts a blob, takes a whole blob, or starts a chunked upload. */
async function startBlobUpload(request: Request, url: URL, target: Target, creds: Credentials) {
  const mount = url.searchParams.get("mount");
  const from = url.searchParams.get("from");
  if (mount && from && isDigest(mount)) {
    const source = authorize(creds, from, "pull");
    if (!(source instanceof Response) && mountBlob(target.repo.id, source.repo.id, mount)) return blobCreated(target, mount);
    // Not mountable: the client uploads the blob instead.
  }

  const upload = startUpload(target.repo.id);
  const digest = url.searchParams.get("digest");
  if (digest) {
    if (!isDigest(digest)) {
      cancelUpload(upload);
      return error(400, "DIGEST_INVALID", "Invalid digest.");
    }
    await appendOrCancel(upload, request.body);
    await finishUpload(upload, digest);
    return blobCreated(target, digest);
  }
  return reply(202, { ...uploadHeaders(target, upload, 0), "Content-Length": "0" });
}

async function appendOrCancel(upload: Upload, body: ReadableStream<Uint8Array> | null) {
  try {
    return await appendUpload(upload, body);
  } catch (e) {
    cancelUpload(upload);
    throw e;
  }
}

/** /v2/<name>/blobs/uploads/<id>: status, a chunk, the final chunk with the digest, or cancel. */
async function continueBlobUpload(request: Request, url: URL, target: Target, id: string) {
  const upload = findUpload(target.repo.id, id);
  if (!upload) return error(404, "BLOB_UPLOAD_UNKNOWN", "Upload not found. It may have expired.");

  switch (request.method) {
    case "GET":
      return reply(204, uploadHeaders(target, upload, upload.size));
    case "DELETE":
      cancelUpload(upload);
      return reply(204);
    case "PATCH": {
      // Chunks must arrive in order: "Content-Range: <start>-<end>" has to continue where the upload ends.
      const range = /^(?:bytes )?(\d+)-(\d+)/.exec(request.headers.get("content-range") ?? "");
      if (range && Number(range[1]) !== upload.size) {
        return error(416, "BLOB_UPLOAD_INVALID", "Chunk out of order.", uploadHeaders(target, upload, upload.size));
      }
      const size = await appendUpload(upload, request.body);
      return reply(202, { ...uploadHeaders(target, upload, size), "Content-Length": "0" });
    }
    default: {
      const digest = url.searchParams.get("digest");
      if (!digest || !isDigest(digest)) return error(400, "DIGEST_INVALID", "A valid digest is required to finish an upload.");
      const size = await appendUpload(upload, request.body);
      await finishUpload({ ...upload, size }, digest);
      return blobCreated(target, digest);
    }
  }
}

// ---------------------------------------------------------------------------
// Manifests, tags, referrers

/** Reads a request body, refusing more than `limit` bytes. */
async function readBody(request: Request, limit: number) {
  if (Number(request.headers.get("content-length")) > limit) return null;
  const chunks: Uint8Array[] = [];
  let length = 0;
  if (!request.body) return Buffer.alloc(0);
  for await (const chunk of Readable.fromWeb(request.body as never) as AsyncIterable<Uint8Array>) {
    length += chunk.byteLength;
    if (length > limit) return null;
    chunks.push(chunk);
  }
  return Buffer.concat(chunks);
}

async function manifest(request: Request, target: Target, reference: string) {
  const { repo, image, name } = target;
  const valid = isDigest(reference) || TAG_RE.test(reference);

  if (request.method === "PUT") {
    if (!valid) return error(400, "TAG_INVALID", "Invalid tag.");
    const content = await readBody(request, MAX_MANIFEST_BYTES);
    if (!content) return error(413, "SIZE_INVALID", "Manifests can be at most 4 MB.");
    const { digest, subject } = putManifest(repo.id, image, reference, request.headers.get("content-type"), content);
    return reply(201, {
      Location: `/v2/${name}/manifests/${digest}`,
      "Docker-Content-Digest": digest,
      ...(subject ? { "OCI-Subject": subject } : {}),
    });
  }

  if (request.method === "DELETE") {
    // A digest deletes the manifest and its tags; a tag only removes the tag.
    const deleted = isDigest(reference) ? deleteManifest(repo.id, image, reference) : valid && deleteTag(repo.id, image, reference);
    return deleted ? reply(202) : error(404, "MANIFEST_UNKNOWN", "Manifest not found.");
  }

  const found = valid ? findManifest(repo.id, image, reference) : null;
  if (!found) return error(404, "MANIFEST_UNKNOWN", `Manifest ${reference} not found.`);
  const headers = {
    "Content-Type": found.mediaType,
    "Content-Length": String(found.content.byteLength),
    "Docker-Content-Digest": found.digest,
  };
  return reply(200, headers, request.method === "HEAD" ? null : Buffer.from(found.content));
}

/** GET /v2/<name>/tags/list?n=<count>&last=<tag> */
function tags(url: URL, { repo, image, name }: Target) {
  const nParam = url.searchParams.get("n");
  const n = nParam === null ? undefined : Math.max(0, Math.floor(Number(nParam)) || 0);
  const last = url.searchParams.get("last") ?? undefined;
  const list = n === 0 ? [] : listTags(repo.id, image, { n, last });
  const headers: Record<string, string> = {};
  if (n && list.length === n) {
    const next = new URLSearchParams({ n: String(n), last: list[list.length - 1] });
    headers.Link = `</v2/${name}/tags/list?${next}>; rel="next"`;
  }
  return json(200, { name, tags: list }, headers);
}

/** GET /v2/<name>/referrers/<digest>?artifactType=<type> */
function referrers(url: URL, { repo, image }: Target, digest: string) {
  if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest.");
  const artifactType = url.searchParams.get("artifactType");
  const index = { schemaVersion: 2, mediaType: MEDIA_TYPES.ociIndex, manifests: listReferrers(repo.id, image, digest, artifactType) };
  return json(200, index, {
    "Content-Type": MEDIA_TYPES.ociIndex,
    ...(artifactType ? { "OCI-Filters-Applied": "artifactType" } : {}),
  });
}