Vectrum
Sign In
JjohannSoAdd wikisHistory
254 lines · 12.6 KB · TypeScript
import "server-only";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { getViewer } from "./auth";
import { normalizeRepoName, revParse, spawnArchive, type ArchiveFormat } from "./git";
import {
  assetPath,
  cleanAssetName,
  countDownload,
  createRelease,
  deleteAsset,
  findRelease,
  latestRelease,
  ReleaseError,
  storeAsset,
  tagCommit,
  type Release,
} from "./releases";
import { canWrite, findRepo, repoAccess, type Access, type RepoRecord } from "./repos";
import { verifyUpload } from "./release-upload";
import { authenticateToken, basicAuthToken, tokenCovers } from "./tokens";

// Downloading release files and source archives, and uploading release files from scripts:
//
//   GET    /<owner>/<repo>/releases/download/<tag>/<file>
//   GET    /<owner>/<repo>/releases/latest/download/<file>
//   PUT    /<owner>/<repo>/releases/download/<tag>/<file>   (curl -T <file> -u <you>:vec_… <url>,
//                                                             or ?upload=… from an agent's signed URL)
//   DELETE /<owner>/<repo>/releases/download/<tag>/<file>
//   GET    /<owner>/<repo>/archive/<ref>.zip, .tar.gz
//
// Requests are signed in with the browser session, or an API token as HTTP Basic password or
// Bearer token, like git over HTTPS.

const text = (status: number, message: string, headers: Record<string, string> = {}) =>
  new Response(`${message}\n`, { status, headers: { "Content-Type": "text/plain; charset=utf-8", ...headers } });

const notFound = () => text(404, "Not found.");

type Caller = { repo: RepoRecord; access: Exclude<Access, null>; userId: string | null };

async function tokenCredentials(request: Request) {
  const header = request.headers.get("authorization");
  if (header?.startsWith("Bearer ")) return (await authenticateToken(header.slice(7).trim())) ?? "invalid";
  return basicAuthToken(request);
}

/** Whether a browser request comes from a page of this site; cookie-signed writes must. */
function sameOrigin(request: Request) {
  const origin = request.headers.get("origin");
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  if (!origin || !host) return false;
  try {
    return new URL(origin).host === host;
  } catch {
    return false;
  }
}

/**
 * Finds the repository and checks the caller's access. Repositories the caller can't read are
 * 404s, so private ones aren't revealed; writing needs push access (and a read & write token).
 */
async function authorize(request: Request, owner: string, repoParam: string, need: "read" | "write"): Promise<Caller | Response> {
  const repo = await findRepo(owner, normalizeRepoName(repoParam));
  const writing = need === "write";
  const creds = await tokenCredentials(request);
  if (creds === "invalid") return text(401, "Invalid or expired token.", { "WWW-Authenticate": 'Basic realm="Vectrum"' });

  const userId = creds ? creds.user.id : ((await getViewer())?.id ?? null);
  if (!creds && writing) {
    if (!userId) return text(401, "Authentication required. Use an API token as the password.", { "WWW-Authenticate": 'Basic realm="Vectrum"' });
    if (!sameOrigin(request)) return text(403, "Cross-origin request refused.");
  }

  const access = repo ? await repoAccess(repo, userId) : null;
  if (!repo || !access) return notFound();
  if (creds && !tokenCovers(creds.token, repo.id, false)) return text(403, "This token does not grant access to this repository.");
  if (creds && writing && creds.token.permission !== "write") return text(403, "This token is read-only. Create a read & write token to upload.");
  if (writing && !canWrite(access)) return text(403, `Permission to ${repo.owner}/${repo.name} denied.`);
  return { repo, access, userId };
}

/** `filename` for Content-Disposition: an ASCII fallback, and the real name for clients that read it. */
function attachment(name: string) {
  const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
  return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}

/** Splits download URL segments into the tag (which may contain slashes) and the file name. */
function splitTagAndName(segments: string[]) {
  if (segments.length < 2) return null;
  return { tag: segments.slice(0, -1).join("/"), name: segments[segments.length - 1] };
}

/** Drafts are only visible to people who can edit them. */
const visible = (release: Release | null, caller: Caller) => (release && (!release.draft || canWrite(caller.access)) ? release : null);

async function serveAsset(request: Request, caller: Caller, release: Release, name: string) {
  const asset = release.assets.find((a) => a.name === name);
  const size = asset && (await stat(assetPath(release, asset)).catch(() => null))?.size;
  if (!asset || size == null) return notFound();

  const etag = `"${asset.sha256}"`;
  const headers: Record<string, string> = {
    "Content-Type": "application/octet-stream",
    "Content-Disposition": attachment(asset.name),
    "X-Content-Type-Options": "nosniff",
    "Content-Security-Policy": "default-src 'none'; sandbox",
    "Cache-Control": caller.repo.visibility === "private" ? "private, no-cache" : "no-cache",
    "Accept-Ranges": "bytes",
    ETag: etag,
  };
  if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });

  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 new Response(null, { status: 416, headers: { ...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 new Response(null, { status, headers });

  // Resumed downloads ask for later ranges; only count the ones that start at the beginning.
  if (start === 0) await countDownload(asset);
  const stream = Readable.toWeb(createReadStream(assetPath(release, asset), { start, end })) as ReadableStream<Uint8Array>;
  return new Response(stream, { status, headers });
}

/** GET and HEAD /<owner>/<repo>/releases/download/<tag>/<file> */
export async function downloadAsset(request: Request, owner: string, repoParam: string, segments: string[]) {
  const target = splitTagAndName(segments);
  if (!target) return notFound();
  const caller = await authorize(request, owner, repoParam, "read");
  if (caller instanceof Response) return caller;
  const release = visible(await findRelease(caller.repo.id, target.tag), caller);
  return release ? serveAsset(request, caller, release, target.name) : notFound();
}

/** GET and HEAD /<owner>/<repo>/releases/latest/download/<file> */
export async function downloadLatestAsset(request: Request, owner: string, repoParam: string, name: string) {
  const caller = await authorize(request, owner, repoParam, "read");
  if (caller instanceof Response) return caller;
  const release = await latestRelease(caller.repo.id);
  return release ? serveAsset(request, caller, release, name) : notFound();
}

/**
 * PUT /<owner>/<repo>/releases/download/<tag>/<file>: uploads a file, replacing one of the same name.
 * Uploading to a pushed tag that has no release yet publishes a release for it.
 */
export async function uploadAsset(request: Request, owner: string, repoParam: string, segments: string[]) {
  const target = splitTagAndName(segments);
  if (!target) return notFound();
  const signed = new URL(request.url).searchParams.get("upload");
  const caller = signed ? await authorizeSigned(signed, owner, repoParam, target) : await authorize(request, owner, repoParam, "write");
  if (caller instanceof Response) return caller;
  if (!request.body) return text(400, "Send the file as the request body.");
  if (!cleanAssetName(target.name)) return text(400, `${target.name} is not a valid file name.`);

  try {
    let release = await findRelease(caller.repo.id, target.tag);
    // A signed URL names its release; it can't create one.
    if ("releaseId" in caller && release?.id !== caller.releaseId) return text(403, "This upload URL is for another release.");
    if (!release) {
      if (!(await tagCommit(caller.repo, target.tag))) {
        return text(404, `There is no release or tag named ${target.tag}. Push the tag or create the release first.`);
      }
      const input = { tag: target.tag, target: "", title: "", body: "", prerelease: false, draft: false };
      release = (await createRelease(caller.repo, caller.userId, input)).release;
    }
    const asset = await storeAsset(release, target.name, request.body, caller.userId);
    const url = `/${caller.repo.owner}/${caller.repo.name}/releases/download/${segments.map(encodeURIComponent).join("/")}`;
    return Response.json({ name: asset.name, size: asset.size, sha256: asset.sha256, url }, { status: 201, headers: { Location: url } });
  } catch (e) {
    if (e instanceof ReleaseError) return text(400, e.message);
    throw e;
  }
}

/**
 * An upload with a signed URL (lib/release-upload.ts) instead of credentials: allowed for the one
 * file it names, while its signer can still push to the repository.
 */
async function authorizeSigned(value: string, owner: string, repoParam: string, target: { tag: string; name: string }) {
  const upload = verifyUpload(value);
  if (upload === "expired") return text(401, "This upload URL has expired. Ask for a new one.");
  const repo = await findRepo(owner, normalizeRepoName(repoParam));
  if (!upload || !repo || upload.repoId !== repo.id) return text(403, "Invalid upload URL.");
  if (upload.name !== target.name) return text(403, `This upload URL is for ${upload.name}, not ${target.name}.`);
  const access = await repoAccess(repo, upload.userId);
  if (!canWrite(access)) return text(403, `Permission to ${repo.owner}/${repo.name} denied.`);
  return { repo, access: access!, userId: upload.userId, releaseId: upload.releaseId };
}

/** DELETE /<owner>/<repo>/releases/download/<tag>/<file> */
export async function removeAsset(request: Request, owner: string, repoParam: string, segments: string[]) {
  const target = splitTagAndName(segments);
  if (!target) return notFound();
  const caller = await authorize(request, owner, repoParam, "write");
  if (caller instanceof Response) return caller;
  const release = await findRelease(caller.repo.id, target.tag);
  const asset = release?.assets.find((a) => a.name === target.name);
  if (!release || !asset) return notFound();
  await deleteAsset(release, asset);
  return new Response(null, { status: 204 });
}

const ARCHIVE_TYPES: Record<ArchiveFormat, string> = { zip: "application/zip", "tar.gz": "application/gzip" };

/** A ref from an archive URL: refs/tags/<tag>, refs/heads/<branch>, a bare tag or branch name, or a commit. */
async function resolveArchiveRef(dir: string, ref: string) {
  if (ref.startsWith("refs/tags/") || ref.startsWith("refs/heads/")) return revParse(dir, ref);
  if (/^[0-9a-f]{7,64}$/i.test(ref)) {
    const sha = await revParse(dir, ref);
    if (sha) return sha;
  }
  return (await revParse(dir, `refs/tags/${ref}`)) ?? (await revParse(dir, `refs/heads/${ref}`));
}

/** GET /<owner>/<repo>/archive/<ref>.zip and .tar.gz: the source code at a tag, branch or commit. */
export async function downloadArchive(request: Request, owner: string, repoParam: string, segments: string[]) {
  const m = /^(.+)\.(zip|tar\.gz)$/.exec(segments.join("/"));
  if (!m) return notFound();
  const ref = m[1];
  const format = m[2] as ArchiveFormat;
  const caller = await authorize(request, owner, repoParam, "read");
  if (caller instanceof Response) return caller;
  const sha = await resolveArchiveRef(caller.repo.dir, ref);
  if (!sha) return notFound();

  // Like GitHub: <repo>-<tag>/ inside, with slashes in the ref turned into dashes.
  const prefix = `${caller.repo.name}-${ref.replace(/^refs\/(tags|heads)\//, "").replace(/\//g, "-")}`;
  const child = spawnArchive(caller.repo.dir, sha, format, prefix);
  request.signal.addEventListener("abort", () => child.kill());
  child.stderr.on("data", (d) => console.error(`[git archive] ${d}`));
  return new Response(Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>, {
    headers: {
      "Content-Type": ARCHIVE_TYPES[format],
      "Content-Disposition": attachment(`${prefix}.${format}`),
      "X-Content-Type-Options": "nosniff",
      "Cache-Control": caller.repo.visibility === "private" ? "private, no-cache" : "no-cache",
    },
  });
}