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 { authenticateToken, basicAuthToken, tokenCovers } from "./tokens"; // Downloading release files and source archives, and uploading release files from scripts: // // GET ///releases/download// // GET ///releases/latest/download/ // PUT ///releases/download// (curl -T -u :vec_… ) // DELETE ///releases/download// // GET ///archive/.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 = {}) => 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; 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 { 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 = { "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; return new Response(stream, { status, headers }); } /** GET and HEAD ///releases/download// */ 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 ///releases/latest/download/ */ 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 ///releases/download//: 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 caller = 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); 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; } } /** DELETE ///releases/download// */ 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 = { zip: "application/zip", "tar.gz": "application/gzip" }; /** A ref from an archive URL: refs/tags/, refs/heads/, 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 ///archive/.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: -/ 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, { 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", }, }); }