Vectrum
Sign In

Add a container registry to every repository

Each repository is also a Docker/OCI registry under /v2, like ghcr.io:
images are named <host>/<owner>/<repo>[/<name>] and share the
repository's visibility, collaborators and API tokens. Supports
multi-platform images, chunked uploads, cross-repository mounts, tag
pagination and the referrers API. Blobs are content-addressed on disk
and garbage-collected once no manifest references them. A Packages tab
lists images and lets writers delete versions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent aecb85ccommitddc1331

Showing 18 changed files with 1129 additions and 32 deletions.

+26−0README.mdView File
@@ -11,6 +11,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or
1111 - **Collaborators**: give other users push access to a repository
1212 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1313 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
14+ - **Container registry**: every repository is also a Docker/OCI registry, like ghcr.io. `docker push <host>/<owner>/<repo>` (or `<host>/<owner>/<repo>/<name>` for more images per repository) with the same visibility, collaborators and API tokens as the code. Multi-platform images, cross-repository blob mounts, chunked uploads and the referrers API (signatures, SBOMs) are supported. Images are listed under the repository's *Packages* tab, where writers can delete versions
1415 - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored
1516 - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one
1617 - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews
@@ -57,6 +58,24 @@ git clone http://localhost:3000/<you>/<repo>.git
5758
5859 Anonymous users can clone public repositories without a token.
5960
61+ ### Pushing container images
62+
63+ Log in with an API token as the password (read & write to push), then tag and
64+ push images under a repository's name. Image names are lowercase:
65+
66+ ```bash
67+ docker login localhost:3000 -u <you> # Password: vec_…
68+ docker build -t localhost:3000/<you>/<repo>:latest .
69+ docker push localhost:3000/<you>/<repo>:latest
70+ docker push localhost:3000/<you>/<repo>/worker:1.0 # more images in one repository
71+ ```
72+
73+ Anyone who can read the repository can pull; anonymous users can pull images of
74+ public repositories. The repository has to exist before you push. Docker only
75+ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add
76+ the host to Docker's `insecure-registries`). A reverse proxy in front of
77+ Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`).
78+
6079 ## Configuration
6180
6281 | Variable | Default | Purpose |
@@ -74,8 +93,14 @@ data/
7493 app.db users, sessions, keys, tokens, repos, issues (SQLite)
7594 ssh_host_ed25519_key generated on first start
7695 repos/<owner>/<repo>.git bare git repositories
96+ registry/blobs/sha256/… container image layers, shared by all repositories
97+ registry/uploads/ image uploads in progress
7798 ```
7899
100+ Image manifests and tags are stored in the database. A blob is deleted once no
101+ manifest of any repository references it (blobs pushed in the last 24 hours are
102+ kept so pushes in progress don't lose their layers).
103+
79104 ## Security notes
80105
81106 - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set.
@@ -91,4 +116,5 @@ data/
91116 - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
92117 - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories
93118 - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`)
119+ - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2`
94120 - `app/actions/*`: server actions, each re-checking the session and permissions
+2−1app/[owner]/[repo]/layout.tsxView File
@@ -2,6 +2,7 @@ import Link from "next/link";
22 import { RepoTabs } from "@/components/repo-tabs";
33 import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui";
44 import { openIssueCount } from "@/lib/issues";
5+ import { countImages } from "@/lib/registry";
56 import { getRepo } from "@/lib/repo";
67
78 export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) {
@@ -37,7 +38,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
3738 <VisibilityLabel visibility={repo.visibility} />
3839 {repo.access === "write" && <Label tone="accent">Collaborator</Label>}
3940 </div>
40- <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} showSettings={repo.isAdmin} />
41+ <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} />
4142 </div>
4243 </div>
4344 <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div>
+141−0app/[owner]/[repo]/packages/page.tsxaddedView File
@@ -0,0 +1,141 @@
1+ import Link from "next/link";
2+ import type { Metadata } from "next";
3+ import { deleteImageVersionAction } from "@/app/actions/packages";
4+ import { CopyButton, CopyField } from "@/components/clone-button";
5+ import { ConfirmButton } from "@/components/confirm-button";
6+ import { PackageIcon, TagIcon } from "@/components/icons";
7+ import { liftRowClass, plateHeadingClass } from "@/components/repo-rows";
8+ import { Alert, Blankslate, Box, Label, PageTitle, RefChip } from "@/components/ui";
9+ import { formatBytes, timeAgo } from "@/lib/format";
10+ import { isRegistryName, listImages, type ImageSummary, type ImageVersion } from "@/lib/registry";
11+ import { getRepo, imageName, type Repo } from "@/lib/repo";
12+
13+ export const metadata: Metadata = { title: "Packages" };
14+
15+ const platformLabel = (p: ImageVersion["platforms"][number]) => [p.os, p.architecture, p.variant].filter(Boolean).join("/");
16+
17+ function VersionRow({ repo, image, fullName, version }: { repo: Repo; image: string; fullName: string; version: ImageVersion }) {
18+ const pushedAt = Math.max(version.createdAt, ...version.tags.map((t) => t.updatedAt));
19+ const tags = version.tags.map((t) => t.name);
20+ return (
21+ <li className={`${liftRowClass} flex min-h-13 flex-wrap items-center gap-x-3 gap-y-1.5 px-3 py-2`}>
22+ <span className="icon-tile size-8 shrink-0 rounded-row">
23+ <TagIcon width={15} height={15} />
24+ </span>
25+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
26+ {tags.length ? tags.map((t) => <RefChip key={t}>{t}</RefChip>) : <Label>untagged</Label>}
27+ </div>
28+ <span className="flex items-center gap-0.5">
29+ <code className="type-readout text-ink3" title={version.digest}>
30+ {version.digest.slice(7, 19)}
31+ </code>
32+ <CopyButton text={`${fullName}@${version.digest}`} label="Copy image reference with digest" />
33+ </span>
34+ <span className="min-w-0 truncate type-meta text-ink3">
35+ {version.platforms.length > 0 && <>{version.platforms.map(platformLabel).join(", ")} · </>}
36+ <span className="type-readout">{formatBytes(version.size)}</span>
37+ </span>
38+ <span className="ml-auto shrink-0 type-meta text-ink3">
39+ Pushed <time dateTime={new Date(pushedAt).toISOString()} className="type-readout">{timeAgo(pushedAt)}</time>
40+ </span>
41+ {repo.canWrite && (
42+ <ConfirmButton
43+ action={deleteImageVersionAction.bind(null, repo.owner, repo.name, image, version.digest)}
44+ message={
45+ tags.length
46+ ? `Delete ${fullName}:${tags.join(", ")}? Pulling ${tags.length === 1 ? "this tag" : "these tags"} stops working.`
47+ : `Delete this untagged version of ${fullName}? Pulling it by digest stops working.`
48+ }
49+ variant="default"
50+ >
51+ Delete
52+ </ConfirmButton>
53+ )}
54+ </li>
55+ );
56+ }
57+
58+ function ImageCard({ repo, base, summary }: { repo: Repo; base: string; summary: ImageSummary }) {
59+ const fullName = summary.image ? `${base}/${summary.image}` : base;
60+ // "latest" if there is one, else the most recently pushed tag.
61+ const tags = summary.versions.flatMap((v) => v.tags).sort((a, b) => b.updatedAt - a.updatedAt);
62+ const latest = tags.some((t) => t.name === "latest") ? "latest" : tags[0]?.name;
63+ return (
64+ <Box className="p-1.5">
65+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pr-1.5">
66+ <h2 className={`${plateHeadingClass} min-w-0`}>
67+ <PackageIcon width={14} height={14} className="shrink-0" />
68+ <span className="truncate">{fullName}</span>
69+ <span className="type-readout text-ink3 [text-shadow:none]">{summary.versions.length}</span>
70+ </h2>
71+ <div className="ml-auto w-full max-w-md min-w-0 pb-1.5 sm:w-auto sm:flex-1">
72+ <CopyField value={`docker pull ${fullName}${latest ? `:${latest}` : ""}`} label="Pull command" copyLabel="Copy pull command" />
73+ </div>
74+ </div>
75+ <ul>
76+ {summary.versions.map((v) => (
77+ <VersionRow key={v.digest} repo={repo} image={summary.image} fullName={fullName} version={v} />
78+ ))}
79+ </ul>
80+ </Box>
81+ );
82+ }
83+
84+ function PushInstructions({ repo, base }: { repo: Repo; base: string }) {
85+ const commands = `docker login ${base.split("/")[0]} -u ${repo.viewer?.username ?? "<username>"}\ndocker build -t ${base}:latest .\ndocker push ${base}:latest`;
86+ return (
87+ <Box className="px-6 py-5">
88+ <h3 className="mb-1 type-title text-ink">Push an image</h3>
89+ <p className="mb-4 type-lead text-ink2">
90+ Log in with an{" "}
91+ <Link href="/settings/tokens" className="text-ink underline decoration-rule underline-offset-2 hover:decoration-current">
92+ API token
93+ </Link>{" "}
94+ with read &amp; write access as the password. Images can also be named <code className="font-mono text-[12px]">{base}/&lt;name&gt;</code>.
95+ </p>
96+ <div className="flex items-start gap-2">
97+ <pre className="well flex-1 overflow-x-auto rounded-plate px-4 py-3 font-mono text-[11.5px] leading-6 text-ink">{commands}</pre>
98+ <CopyButton text={commands} label="Copy commands" />
99+ </div>
100+ </Box>
101+ );
102+ }
103+
104+ export default async function PackagesPage({ params }: PageProps<"/[owner]/[repo]/packages">) {
105+ const { owner, repo: repoParam } = await params;
106+ const repo = await getRepo(owner, repoParam);
107+ const images = listImages(repo.id);
108+ const base = await imageName(repo);
109+
110+ return (
111+ <div>
112+ <PageTitle
113+ title="Packages"
114+ description={
115+ <>
116+ Container images in this repository’s registry, <code className="font-mono text-[13px]">{base}</code>. They share its visibility and access.
117+ </>
118+ }
119+ />
120+ <div className="flex flex-col gap-4">
121+ {!isRegistryName(repo.owner, repo.name) && (
122+ <Alert kind="info">
123+ Image names are letters and digits joined by <code>.</code>, <code>_</code> or <code>-</code>, so this repository can’t hold
124+ container images under its current name. Rename it to push images.
125+ </Alert>
126+ )}
127+ {images.length === 0 && (
128+ <Box>
129+ <Blankslate icon={<PackageIcon />} title="No container images yet">
130+ {repo.canWrite ? "Push an image with Docker or any OCI client." : "Nothing has been pushed to this repository’s registry yet."}
131+ </Blankslate>
132+ </Box>
133+ )}
134+ {images.map((summary) => (
135+ <ImageCard key={summary.image} repo={repo} base={base} summary={summary} />
136+ ))}
137+ {repo.canWrite && isRegistryName(repo.owner, repo.name) && <PushInstructions repo={repo} base={base} />}
138+ </div>
139+ </div>
140+ );
141+ }
+12−0app/actions/packages.tsaddedView File
@@ -0,0 +1,12 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { deleteImageVersion } from "@/lib/registry";
5+ import { repoForAction } from "./guard";
6+
7+ /** Deletes a container image version with its tags, platform images and attached signatures. */
8+ export async function deleteImageVersionAction(owner: string, name: string, image: string, digest: string) {
9+ const { repo } = await repoForAction(owner, name, "write");
10+ deleteImageVersion(repo.id, image, digest);
11+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
12+ }
+6−0app/v2/[[...path]]/route.tsaddedView File
@@ -0,0 +1,6 @@
1+ import { handleRegistry } from "@/lib/registry-http";
2+
3+ // The container registry API: /v2/<owner>/<repo>[/<path>]/{manifests,blobs,tags,referrers}/…
4+ const handler = (request: Request) => handleRegistry(request);
5+
6+ export { handler as GET, handler as HEAD, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };
+13−12components/clone-button.tsxView File
@@ -30,6 +30,18 @@ export function CopyButton({ text, label = "Copy" }: { text: string; label?: str
3030 );
3131 }
3232
33+ /** A read-only mono field (a URL, a command) with its copy key. */
34+ export function CopyField({ value, label, copyLabel }: { value: string; label: string; copyLabel: string }) {
35+ return (
36+ <Field size="regular" className="pr-1">
37+ <Field.Input readOnly value={value} aria-label={label} className="font-mono text-[11.5px]" onFocus={(e) => e.target.select()} />
38+ <Field.Trail>
39+ <CopyButton text={value} label={copyLabel} />
40+ </Field.Trail>
41+ </Field>
42+ );
43+ }
44+
3345 export type CloneUrls = { https: string; ssh: string };
3446
3547 type Protocol = keyof CloneUrls;
@@ -51,18 +63,7 @@ export function CloneUrlTabs({ urls }: { urls: CloneUrls }) {
5163 ]}
5264 className="self-start"
5365 />
54- <Field size="regular" className="pr-1">
55- <Field.Input
56- readOnly
57- value={url}
58- aria-label="Clone URL"
59- className="font-mono text-[11.5px]"
60- onFocus={(e) => e.target.select()}
61- />
62- <Field.Trail>
63- <CopyButton text={url} label="Copy URL" />
64- </Field.Trail>
65- </Field>
66+ <CopyField value={url} label="Clone URL" copyLabel="Copy URL" />
6667 <p className="type-meta text-ink3">
6768 {protocol === "https" ? (
6869 <>
+7−0components/icons.tsxView File
@@ -87,6 +87,13 @@ export const RepoIcon = icon(
8787 <path d="M12 22V12" />
8888 </>,
8989 );
90+ export const PackageIcon = icon(
91+ <>
92+ <path d="m12 3 9 4.5-9 4.5-9-4.5Z" />
93+ <path d="m3 12 9 4.5 9-4.5" />
94+ <path d="m3 16.5 9 4.5 9-4.5" />
95+ </>,
96+ );
9097 export const CodeIcon = icon(
9198 <>
9299 <path d="m16 18 6-6-6-6" />
+14−3components/repo-tabs.tsxView File
@@ -3,21 +3,32 @@
33 import Link from "next/link";
44 import { usePathname } from "next/navigation";
55 import { useState } from "react";
6- import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon } from "./icons";
6+ import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon } from "./icons";
77 import { SlidingIndicator, thumbClass } from "./sliding-indicator";
88
99 /**
1010 * A repository's sections on the switcher track: they are pages, so they are links (not MetalUI Tabs),
1111 * with the switcher's raised thumb gliding under the current one.
1212 */
13- export function RepoTabs({ base, openIssues, showSettings }: { base: string; openIssues: number; showSettings: boolean }) {
13+ export function RepoTabs({
14+ base,
15+ openIssues,
16+ images,
17+ showSettings,
18+ }: {
19+ base: string;
20+ openIssues: number;
21+ images: number;
22+ showSettings: boolean;
23+ }) {
1424 const pathname = usePathname();
1525 const section = pathname.slice(base.length).split("/")[1] ?? "";
1626 const tabs = [
17- { href: base, label: "Code", icon: CodeIcon, active: !["issues", "branches", "commits", "commit", "settings"].includes(section) },
27+ { href: base, label: "Code", icon: CodeIcon, active: !["issues", "branches", "commits", "commit", "packages", "settings"].includes(section) },
1828 { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" },
1929 { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" },
2030 { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" },
31+ { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" },
2132 ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []),
2233 ];
2334
+59−0lib/db.tsView File
@@ -124,6 +124,65 @@ CREATE TABLE IF NOT EXISTS issue_comments (
124124 body TEXT NOT NULL,
125125 created_at INTEGER NOT NULL
126126 );
127+
128+ -- Container registry. Blob contents live under registry/blobs, shared by all repositories.
129+ CREATE TABLE IF NOT EXISTS registry_blobs (
130+ digest TEXT PRIMARY KEY, -- sha256:…
131+ size INTEGER NOT NULL,
132+ created_at INTEGER NOT NULL
133+ );
134+
135+ -- The blobs a repository may serve: pushed to it or mounted from another repository.
136+ -- created_at is refreshed on use; unreferenced links are pruned once it's old.
137+ CREATE TABLE IF NOT EXISTS registry_repo_blobs (
138+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
139+ digest TEXT NOT NULL REFERENCES registry_blobs(digest) ON DELETE CASCADE,
140+ created_at INTEGER NOT NULL,
141+ PRIMARY KEY (repo_id, digest)
142+ );
143+
144+ CREATE TABLE IF NOT EXISTS registry_uploads (
145+ id TEXT PRIMARY KEY, -- file name under registry/uploads
146+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
147+ size INTEGER NOT NULL,
148+ created_at INTEGER NOT NULL,
149+ updated_at INTEGER NOT NULL
150+ );
151+
152+ -- image is the name below owner/repo ('' for the repository's own image).
153+ CREATE TABLE IF NOT EXISTS registry_manifests (
154+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
155+ image TEXT NOT NULL,
156+ digest TEXT NOT NULL,
157+ media_type TEXT NOT NULL,
158+ content BLOB NOT NULL,
159+ size INTEGER NOT NULL, -- config and layers, compressed
160+ subject TEXT, -- digest of the manifest this one is attached to
161+ artifact_type TEXT,
162+ created_at INTEGER NOT NULL,
163+ PRIMARY KEY (repo_id, image, digest)
164+ );
165+
166+ -- What a manifest references: its config and layer blobs, or an index's manifests.
167+ CREATE TABLE IF NOT EXISTS registry_manifest_refs (
168+ repo_id INTEGER NOT NULL,
169+ image TEXT NOT NULL,
170+ manifest_digest TEXT NOT NULL,
171+ digest TEXT NOT NULL,
172+ PRIMARY KEY (repo_id, image, manifest_digest, digest),
173+ FOREIGN KEY (repo_id, image, manifest_digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE
174+ );
175+ CREATE INDEX IF NOT EXISTS registry_manifest_refs_digest ON registry_manifest_refs (repo_id, digest);
176+
177+ CREATE TABLE IF NOT EXISTS registry_tags (
178+ repo_id INTEGER NOT NULL,
179+ image TEXT NOT NULL,
180+ tag TEXT NOT NULL,
181+ digest TEXT NOT NULL,
182+ updated_at INTEGER NOT NULL,
183+ PRIMARY KEY (repo_id, image, tag),
184+ FOREIGN KEY (repo_id, image, digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE
185+ );
127186 `;
128187
129188 function open() {
+2−1lib/format.tsView File
@@ -24,7 +24,8 @@ export function formatDate(ms: number) {
2424 export function formatBytes(n: number) {
2525 if (n < 1024) return `${n} Bytes`;
2626 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
27- return `${(n / 1024 / 1024).toFixed(1)} MB`;
27+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
28+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
2829 }
2930
3031 export function shortSha(sha: string) {
+2−11lib/git-http.tsView File
@@ -3,7 +3,7 @@ import { Readable } from "node:stream";
33 import { createGunzip } from "node:zlib";
44 import { normalizeRepoName, spawnService, type GitService } from "./git";
55 import { canWrite, findRepo, markPushed, repoAccess, type RepoRecord } from "./repos";
6- import { authenticateToken, tokenCovers } from "./tokens";
6+ import { basicAuthToken, tokenCovers } from "./tokens";
77
88 function pktLine(s: string) {
99 return (s.length + 4).toString(16).padStart(4, "0") + s;
@@ -26,15 +26,6 @@ const unauthorized = (message: string) =>
2626
2727 const notFound = () => new Response("Repository not found.\n", { status: 404 });
2828
29- /** Credentials are HTTP Basic auth with an API token as the password (username is ignored). */
30- function credentials(request: Request) {
31- const header = request.headers.get("authorization");
32- if (!header?.startsWith("Basic ")) return null;
33- const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
34- const password = decoded.slice(decoded.indexOf(":") + 1);
35- return authenticateToken(password) ?? "invalid";
36- }
37-
3829 /**
3930 * Decides whether a git request may proceed. Anonymous users can clone public
4031 * repositories; everything else needs a token that covers the repository.
@@ -42,7 +33,7 @@ function credentials(request: Request) {
4233 function authorize(request: Request, owner: string, repoParam: string, service: GitService): RepoRecord | Response {
4334 const repo = findRepo(owner, normalizeRepoName(repoParam));
4435 const pushing = service === "git-receive-pack";
45- const creds = credentials(request);
36+ const creds = basicAuthToken(request);
4637 if (creds === "invalid") return unauthorized("Invalid or expired token.");
4738
4839 if (!creds) {
+1−1lib/git.tsView File
@@ -8,7 +8,7 @@ import { REPOS_ROOT } from "./paths";
88
99 const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1010 const RESERVED = new Set([
11- "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin",
11+ "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
1212 ]);
1313
1414 export function isValidName(name: string) {
+334−0lib/registry-http.tsaddedView File
@@ -0,0 +1,334 @@
1+ import "server-only";
2+ import { createReadStream } from "node:fs";
3+ import { stat } from "node:fs/promises";
4+ import { Readable } from "node:stream";
5+ import {
6+ appendUpload,
7+ cancelUpload,
8+ deleteManifest,
9+ deleteTag,
10+ findBlob,
11+ findManifest,
12+ findUpload,
13+ finishUpload,
14+ isDigest,
15+ isImagePath,
16+ listReferrers,
17+ listTags,
18+ MAX_MANIFEST_BYTES,
19+ MEDIA_TYPES,
20+ mountBlob,
21+ putManifest,
22+ RegistryError,
23+ startUpload,
24+ TAG_RE,
25+ unlinkBlob,
26+ type Upload,
27+ } from "./registry";
28+ import { canWrite, findRepo, repoAccess, type RepoRecord } from "./repos";
29+ import { basicAuthToken, tokenCovers } from "./tokens";
30+
31+ // The OCI distribution API (Docker Registry HTTP API v2) under /v2. Clients log in with HTTP Basic
32+ // auth, an API token as the password, like git over HTTPS: `docker login <host> -u <you>`.
33+
34+ const API_HEADERS = { "Docker-Distribution-API-Version": "registry/2.0" };
35+
36+ function reply(status: number, headers: Record<string, string> = {}, body: BodyInit | null = null) {
37+ return new Response(body, { status, headers: { ...API_HEADERS, ...headers } });
38+ }
39+
40+ function json(status: number, data: unknown, headers: Record<string, string> = {}) {
41+ return reply(status, { "Content-Type": "application/json", ...headers }, JSON.stringify(data));
42+ }
43+
44+ function error(status: number, code: string, message: string, headers: Record<string, string> = {}) {
45+ return json(status, { errors: [{ code, message, detail: null }] }, headers);
46+ }
47+
48+ const unauthorized = (message: string) =>
49+ error(401, "UNAUTHORIZED", message, { "WWW-Authenticate": 'Basic realm="Vectrum"' });
50+
51+ type Target = { repo: RepoRecord; image: string; name: string };
52+ type Credentials = ReturnType<typeof basicAuthToken>;
53+
54+ /**
55+ * Resolves an image name (<owner>/<repo>[/<path>]) and checks that the request may pull from or
56+ * push to it. Anonymous users can pull images of public repositories; everything else needs a
57+ * token that covers the repository, and pushing needs write access to it.
58+ */
59+ function authorize(creds: Credentials, name: string, need: "pull" | "push"): Target | Response {
60+ if (creds === "invalid") return unauthorized("Invalid or expired token.");
61+ const [owner, repoName, ...rest] = name.split("/");
62+ const image = rest.join("/");
63+ if (!repoName || !isImagePath(name)) return error(400, "NAME_INVALID", `Invalid image name ${name}. Names are lowercase.`);
64+ const repo = findRepo(owner, repoName);
65+
66+ if (!creds) {
67+ if (repo && need === "pull" && repo.visibility === "public") return { repo, image, name };
68+ // Ask for credentials; don't reveal whether private repositories exist.
69+ return unauthorized("Authentication required. Log in with an API token as the password.");
70+ }
71+
72+ const access = repo ? repoAccess(repo, creds.user.id) : null;
73+ if (!repo || !access) {
74+ return error(404, "NAME_UNKNOWN", `No repository ${owner}/${repoName}. Images are pushed to an existing repository.`);
75+ }
76+ const pushing = need === "push";
77+ if (pushing && creds.token.permission !== "write") {
78+ return error(403, "DENIED", "This token is read-only. Create a read & write token to push.");
79+ }
80+ if (!tokenCovers(creds.token, repo.id, pushing)) {
81+ return error(403, "DENIED", "This token does not grant access to this repository.");
82+ }
83+ if (pushing && !canWrite(access)) return error(403, "DENIED", `Permission to ${repo.owner}/${repo.name} denied.`);
84+ return { repo, image, name };
85+ }
86+
87+ const ROUTES = [
88+ { kind: "uploads", re: /^\/(.+)\/blobs\/uploads\/?$/ },
89+ { kind: "upload", re: /^\/(.+)\/blobs\/uploads\/([^/]+)$/ },
90+ { kind: "blob", re: /^\/(.+)\/blobs\/([^/]+)$/ },
91+ { kind: "manifest", re: /^\/(.+)\/manifests\/([^/]+)$/ },
92+ { kind: "tags", re: /^\/(.+)\/tags\/list$/ },
93+ { kind: "referrers", re: /^\/(.+)\/referrers\/([^/]+)$/ },
94+ ] as const;
95+
96+ type Kind = (typeof ROUTES)[number]["kind"];
97+
98+ const PUSH_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
99+ const ALLOWED: Record<Kind, string[]> = {
100+ uploads: ["POST"],
101+ upload: ["GET", "PATCH", "PUT", "DELETE"],
102+ blob: ["GET", "HEAD", "DELETE"],
103+ manifest: ["GET", "HEAD", "PUT", "DELETE"],
104+ tags: ["GET"],
105+ referrers: ["GET"],
106+ };
107+
108+ /** Handles every request under /v2. */
109+ export async function handleRegistry(request: Request) {
110+ const url = new URL(request.url);
111+ const rest = url.pathname.replace(/^\/v2/, "");
112+ const creds = basicAuthToken(request);
113+
114+ if (rest === "" || rest === "/") {
115+ if (request.method !== "GET" && request.method !== "HEAD") return error(405, "UNSUPPORTED", "Method not allowed.");
116+ // `docker login` checks its credentials here, so anonymous requests are always challenged.
117+ if (!creds) return unauthorized("Authentication required. Log in with an API token as the password.");
118+ if (creds === "invalid") return unauthorized("Invalid or expired token.");
119+ return json(200, {});
120+ }
121+
122+ for (const { kind, re } of ROUTES) {
123+ const match = re.exec(rest);
124+ if (!match) continue;
125+ const [name, param = ""] = match.slice(1).map(decodeURIComponent);
126+ if (!ALLOWED[kind].includes(request.method)) return error(405, "UNSUPPORTED", "Method not allowed.");
127+ const target = authorize(creds, name, PUSH_METHODS.has(request.method) ? "push" : "pull");
128+ if (target instanceof Response) return target;
129+ try {
130+ switch (kind) {
131+ case "uploads":
132+ return await startBlobUpload(request, url, target, creds);
133+ case "upload":
134+ return await continueBlobUpload(request, url, target, param);
135+ case "blob":
136+ return await blob(request, target, param);
137+ case "manifest":
138+ return await manifest(request, target, param);
139+ case "tags":
140+ return tags(url, target);
141+ case "referrers":
142+ return referrers(url, target, param);
143+ }
144+ } catch (e) {
145+ if (e instanceof RegistryError) return error(e.status, e.code, e.message);
146+ throw e;
147+ }
148+ }
149+ return error(404, "NAME_UNKNOWN", "Not found.");
150+ }
151+
152+ // ---------------------------------------------------------------------------
153+ // Blobs
154+
155+ async function blob(request: Request, { repo }: Target, digest: string) {
156+ if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest.");
157+ if (request.method === "DELETE") {
158+ return unlinkBlob(repo.id, digest) ? reply(202) : error(404, "BLOB_UNKNOWN", "Blob not found.");
159+ }
160+ const found = findBlob(repo.id, digest);
161+ const size = found && (await stat(found.path).catch(() => null))?.size;
162+ if (!found || size == null) return error(404, "BLOB_UNKNOWN", "Blob not found.");
163+
164+ const headers: Record<string, string> = {
165+ "Content-Type": "application/octet-stream",
166+ "Docker-Content-Digest": digest,
167+ "Accept-Ranges": "bytes",
168+ // Content-addressed, so it never changes.
169+ "Cache-Control": "public, max-age=31536000, immutable",
170+ };
171+ let start = 0;
172+ let end = size - 1;
173+ const range = request.headers.get("range");
174+ if (range) {
175+ const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
176+ if (m && (m[1] || m[2])) {
177+ start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
178+ end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
179+ }
180+ if (!m || start > end || start >= size) return reply(416, { ...headers, "Content-Range": `bytes */${size}` });
181+ headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
182+ }
183+ headers["Content-Length"] = String(end - start + 1);
184+ const status = range ? 206 : 200;
185+ if (request.method === "HEAD" || size === 0) return reply(status, headers);
186+ const stream = Readable.toWeb(createReadStream(found.path, { start, end })) as ReadableStream<Uint8Array>;
187+ return reply(status, headers, stream);
188+ }
189+
190+ const uploadHeaders = ({ name }: Target, upload: Upload, size: number) => ({
191+ Location: `/v2/${name}/blobs/uploads/${upload.id}`,
192+ Range: `0-${Math.max(0, size - 1)}`,
193+ "Docker-Upload-UUID": upload.id,
194+ });
195+
196+ const blobCreated = ({ name }: Target, digest: string) =>
197+ reply(201, { Location: `/v2/${name}/blobs/${digest}`, "Docker-Content-Digest": digest });
198+
199+ /** POST /v2/<name>/blobs/uploads/: mounts a blob, takes a whole blob, or starts a chunked upload. */
200+ async function startBlobUpload(request: Request, url: URL, target: Target, creds: Credentials) {
201+ const mount = url.searchParams.get("mount");
202+ const from = url.searchParams.get("from");
203+ if (mount && from && isDigest(mount)) {
204+ const source = authorize(creds, from, "pull");
205+ if (!(source instanceof Response) && mountBlob(target.repo.id, source.repo.id, mount)) return blobCreated(target, mount);
206+ // Not mountable: the client uploads the blob instead.
207+ }
208+
209+ const upload = startUpload(target.repo.id);
210+ const digest = url.searchParams.get("digest");
211+ if (digest) {
212+ if (!isDigest(digest)) {
213+ cancelUpload(upload);
214+ return error(400, "DIGEST_INVALID", "Invalid digest.");
215+ }
216+ await appendOrCancel(upload, request.body);
217+ await finishUpload(upload, digest);
218+ return blobCreated(target, digest);
219+ }
220+ return reply(202, { ...uploadHeaders(target, upload, 0), "Content-Length": "0" });
221+ }
222+
223+ async function appendOrCancel(upload: Upload, body: ReadableStream<Uint8Array> | null) {
224+ try {
225+ return await appendUpload(upload, body);
226+ } catch (e) {
227+ cancelUpload(upload);
228+ throw e;
229+ }
230+ }
231+
232+ /** /v2/<name>/blobs/uploads/<id>: status, a chunk, the final chunk with the digest, or cancel. */
233+ async function continueBlobUpload(request: Request, url: URL, target: Target, id: string) {
234+ const upload = findUpload(target.repo.id, id);
235+ if (!upload) return error(404, "BLOB_UPLOAD_UNKNOWN", "Upload not found. It may have expired.");
236+
237+ switch (request.method) {
238+ case "GET":
239+ return reply(204, uploadHeaders(target, upload, upload.size));
240+ case "DELETE":
241+ cancelUpload(upload);
242+ return reply(204);
243+ case "PATCH": {
244+ // Chunks must arrive in order: "Content-Range: <start>-<end>" has to continue where the upload ends.
245+ const range = /^(?:bytes )?(\d+)-(\d+)/.exec(request.headers.get("content-range") ?? "");
246+ if (range && Number(range[1]) !== upload.size) {
247+ return error(416, "BLOB_UPLOAD_INVALID", "Chunk out of order.", uploadHeaders(target, upload, upload.size));
248+ }
249+ const size = await appendUpload(upload, request.body);
250+ return reply(202, { ...uploadHeaders(target, upload, size), "Content-Length": "0" });
251+ }
252+ default: {
253+ const digest = url.searchParams.get("digest");
254+ if (!digest || !isDigest(digest)) return error(400, "DIGEST_INVALID", "A valid digest is required to finish an upload.");
255+ const size = await appendUpload(upload, request.body);
256+ await finishUpload({ ...upload, size }, digest);
257+ return blobCreated(target, digest);
258+ }
259+ }
260+ }
261+
262+ // ---------------------------------------------------------------------------
263+ // Manifests, tags, referrers
264+
265+ /** Reads a request body, refusing more than `limit` bytes. */
266+ async function readBody(request: Request, limit: number) {
267+ if (Number(request.headers.get("content-length")) > limit) return null;
268+ const chunks: Uint8Array[] = [];
269+ let length = 0;
270+ if (!request.body) return Buffer.alloc(0);
271+ for await (const chunk of Readable.fromWeb(request.body as never) as AsyncIterable<Uint8Array>) {
272+ length += chunk.byteLength;
273+ if (length > limit) return null;
274+ chunks.push(chunk);
275+ }
276+ return Buffer.concat(chunks);
277+ }
278+
279+ async function manifest(request: Request, target: Target, reference: string) {
280+ const { repo, image, name } = target;
281+ const valid = isDigest(reference) || TAG_RE.test(reference);
282+
283+ if (request.method === "PUT") {
284+ if (!valid) return error(400, "TAG_INVALID", "Invalid tag.");
285+ const content = await readBody(request, MAX_MANIFEST_BYTES);
286+ if (!content) return error(413, "SIZE_INVALID", "Manifests can be at most 4 MB.");
287+ const { digest, subject } = putManifest(repo.id, image, reference, request.headers.get("content-type"), content);
288+ return reply(201, {
289+ Location: `/v2/${name}/manifests/${digest}`,
290+ "Docker-Content-Digest": digest,
291+ ...(subject ? { "OCI-Subject": subject } : {}),
292+ });
293+ }
294+
295+ if (request.method === "DELETE") {
296+ // A digest deletes the manifest and its tags; a tag only removes the tag.
297+ const deleted = isDigest(reference) ? deleteManifest(repo.id, image, reference) : valid && deleteTag(repo.id, image, reference);
298+ return deleted ? reply(202) : error(404, "MANIFEST_UNKNOWN", "Manifest not found.");
299+ }
300+
301+ const found = valid ? findManifest(repo.id, image, reference) : null;
302+ if (!found) return error(404, "MANIFEST_UNKNOWN", `Manifest ${reference} not found.`);
303+ const headers = {
304+ "Content-Type": found.mediaType,
305+ "Content-Length": String(found.content.byteLength),
306+ "Docker-Content-Digest": found.digest,
307+ };
308+ return reply(200, headers, request.method === "HEAD" ? null : Buffer.from(found.content));
309+ }
310+
311+ /** GET /v2/<name>/tags/list?n=<count>&last=<tag> */
312+ function tags(url: URL, { repo, image, name }: Target) {
313+ const nParam = url.searchParams.get("n");
314+ const n = nParam === null ? undefined : Math.max(0, Math.floor(Number(nParam)) || 0);
315+ const last = url.searchParams.get("last") ?? undefined;
316+ const list = n === 0 ? [] : listTags(repo.id, image, { n, last });
317+ const headers: Record<string, string> = {};
318+ if (n && list.length === n) {
319+ const next = new URLSearchParams({ n: String(n), last: list[list.length - 1] });
320+ headers.Link = `</v2/${name}/tags/list?${next}>; rel="next"`;
321+ }
322+ return json(200, { name, tags: list }, headers);
323+ }
324+
325+ /** GET /v2/<name>/referrers/<digest>?artifactType=<type> */
326+ function referrers(url: URL, { repo, image }: Target, digest: string) {
327+ if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest.");
328+ const artifactType = url.searchParams.get("artifactType");
329+ const index = { schemaVersion: 2, mediaType: MEDIA_TYPES.ociIndex, manifests: listReferrers(repo.id, image, digest, artifactType) };
330+ return json(200, index, {
331+ "Content-Type": MEDIA_TYPES.ociIndex,
332+ ...(artifactType ? { "OCI-Filters-Applied": "artifactType" } : {}),
333+ });
334+ }
+477−0lib/registry.tsaddedView File
@@ -0,0 +1,477 @@
1+ import "server-only";
2+ import { createHash, randomUUID, type Hash } from "node:crypto";
3+ import { createReadStream, createWriteStream, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4+ import { stat } from "node:fs/promises";
5+ import path from "node:path";
6+ import { Readable, Transform } from "node:stream";
7+ import { pipeline } from "node:stream/promises";
8+ import { db, transaction } from "./db";
9+ import { DATA_ROOT } from "./paths";
10+
11+ // A container registry per repository. Images are named <owner>/<repo> or <owner>/<repo>/<path>;
12+ // `image` below is that path ("" for the repository's own image). Layers and configs are
13+ // content-addressed files shared by all repositories, and a repository may only serve the blobs
14+ // that were pushed to or mounted into it. Manifests and tags live in the database.
15+
16+ const BLOBS_ROOT = path.join(DATA_ROOT, "registry", "blobs");
17+ const UPLOADS_ROOT = path.join(DATA_ROOT, "registry", "uploads");
18+
19+ export const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
20+
21+ /** One path component of an image name, as defined by the OCI distribution spec. */
22+ const COMPONENT_RE = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/;
23+ export const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
24+ const DIGEST_RE = /^(sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$/;
25+
26+ export const isDigest = (s: string) => DIGEST_RE.test(s);
27+ export const isImagePath = (s: string) => s === "" || s.split("/").every((c) => COMPONENT_RE.test(c));
28+
29+ /** Whether owner/repo can be written in an image name (Docker requires lowercase components). */
30+ export const isRegistryName = (owner: string, repo: string) =>
31+ COMPONENT_RE.test(owner.toLowerCase()) && COMPONENT_RE.test(repo.toLowerCase());
32+
33+ export class RegistryError extends Error {
34+ constructor(
35+ readonly status: number,
36+ readonly code: string,
37+ message: string,
38+ ) {
39+ super(message);
40+ }
41+ }
42+
43+ function blobPath(digest: string) {
44+ const [algorithm, hex] = digest.split(":");
45+ return path.join(BLOBS_ROOT, algorithm, hex.slice(0, 2), hex);
46+ }
47+
48+ const uploadPath = (id: string) => path.join(UPLOADS_ROOT, id);
49+
50+ // ---------------------------------------------------------------------------
51+ // Blobs
52+
53+ export type BlobInfo = { digest: string; size: number; path: string };
54+
55+ /** A blob the repository may serve, touching its link so a push in progress keeps it. */
56+ export function findBlob(repoId: number, digest: string): BlobInfo | null {
57+ const row = db
58+ .prepare("SELECT b.size FROM registry_repo_blobs l JOIN registry_blobs b ON b.digest = l.digest WHERE l.repo_id = ? AND l.digest = ?")
59+ .get(repoId, digest) as { size: number } | undefined;
60+ if (!row) return null;
61+ db.prepare("UPDATE registry_repo_blobs SET created_at = ? WHERE repo_id = ? AND digest = ?").run(Date.now(), repoId, digest);
62+ return { digest, size: row.size, path: blobPath(digest) };
63+ }
64+
65+ function linkBlob(repoId: number, digest: string) {
66+ db.prepare(
67+ `INSERT INTO registry_repo_blobs (repo_id, digest, created_at) VALUES (?, ?, ?)
68+ ON CONFLICT (repo_id, digest) DO UPDATE SET created_at = excluded.created_at`,
69+ ).run(repoId, digest, Date.now());
70+ }
71+
72+ /** Makes a blob of another repository available in this one without uploading it again. */
73+ export function mountBlob(repoId: number, fromRepoId: number, digest: string) {
74+ if (!findBlob(fromRepoId, digest)) return false;
75+ linkBlob(repoId, digest);
76+ return true;
77+ }
78+
79+ export function unlinkBlob(repoId: number, digest: string) {
80+ const res = db.prepare("DELETE FROM registry_repo_blobs WHERE repo_id = ? AND digest = ?").run(repoId, digest);
81+ pruneRegistry();
82+ return res.changes > 0;
83+ }
84+
85+ // ---------------------------------------------------------------------------
86+ // Uploads
87+
88+ export type Upload = { id: string; repoId: number; size: number };
89+
90+ // Running hashes of uploads whose chunks arrived in order, so finishing a large layer doesn't read it again.
91+ const g = globalThis as unknown as { __uploadHashes?: Map<string, { hash: Hash; size: number }>; __registryPrunedAt?: number };
92+ const uploadHashes = (g.__uploadHashes ??= new Map());
93+
94+ export function startUpload(repoId: number): Upload {
95+ maybePrune();
96+ const id = randomUUID();
97+ const now = Date.now();
98+ db.prepare("INSERT INTO registry_uploads (id, repo_id, size, created_at, updated_at) VALUES (?, ?, 0, ?, ?)").run(id, repoId, now, now);
99+ mkdirSync(UPLOADS_ROOT, { recursive: true });
100+ writeFileSync(uploadPath(id), "");
101+ uploadHashes.set(id, { hash: createHash("sha256"), size: 0 });
102+ return { id, repoId, size: 0 };
103+ }
104+
105+ export function findUpload(repoId: number, id: string): Upload | null {
106+ const row = db.prepare("SELECT size FROM registry_uploads WHERE id = ? AND repo_id = ?").get(id, repoId) as
107+ | { size: number }
108+ | undefined;
109+ return row ? { id, repoId, size: row.size } : null;
110+ }
111+
112+ /** Appends a request body to an upload and returns its new size. */
113+ export async function appendUpload(upload: Upload, body: ReadableStream<Uint8Array> | null) {
114+ if (!body) return upload.size;
115+ const running = uploadHashes.get(upload.id);
116+ const hash = running?.size === upload.size ? running.hash : null;
117+ uploadHashes.delete(upload.id);
118+ const tap = new Transform({
119+ transform(chunk, _enc, done) {
120+ hash?.update(chunk);
121+ done(null, chunk);
122+ },
123+ });
124+ await pipeline(Readable.fromWeb(body as never), tap, createWriteStream(uploadPath(upload.id), { flags: "a" }));
125+ const { size } = await stat(uploadPath(upload.id));
126+ db.prepare("UPDATE registry_uploads SET size = ?, updated_at = ? WHERE id = ?").run(size, Date.now(), upload.id);
127+ if (hash) uploadHashes.set(upload.id, { hash, size });
128+ return size;
129+ }
130+
131+ async function hashFile(file: string, algorithm: string) {
132+ const hash = createHash(algorithm);
133+ await pipeline(createReadStream(file), hash);
134+ return hash.digest("hex");
135+ }
136+
137+ /** Checks the uploaded content against `digest` and stores it as a blob of the upload's repository. */
138+ export async function finishUpload(upload: Upload, digest: string) {
139+ const file = uploadPath(upload.id);
140+ const [algorithm, expected] = digest.split(":");
141+ const running = uploadHashes.get(upload.id);
142+ uploadHashes.delete(upload.id);
143+ const { size } = await stat(file);
144+ const actual =
145+ algorithm === "sha256" && running?.size === size ? running.hash.digest("hex") : await hashFile(file, algorithm);
146+ if (actual !== expected) {
147+ cancelUpload(upload);
148+ throw new RegistryError(400, "DIGEST_INVALID", "The uploaded content does not match the digest.");
149+ }
150+ // Synchronous from here on, so a concurrent prune can't remove the blob between moving and recording it.
151+ if (!db.prepare("SELECT 1 FROM registry_blobs WHERE digest = ?").get(digest)) {
152+ mkdirSync(path.dirname(blobPath(digest)), { recursive: true });
153+ renameSync(file, blobPath(digest));
154+ }
155+ transaction(() => {
156+ db.prepare("INSERT OR IGNORE INTO registry_blobs (digest, size, created_at) VALUES (?, ?, ?)").run(digest, size, Date.now());
157+ linkBlob(upload.repoId, digest);
158+ db.prepare("DELETE FROM registry_uploads WHERE id = ?").run(upload.id);
159+ });
160+ rmSync(file, { force: true });
161+ return size;
162+ }
163+
164+ export function cancelUpload(upload: Upload) {
165+ uploadHashes.delete(upload.id);
166+ db.prepare("DELETE FROM registry_uploads WHERE id = ?").run(upload.id);
167+ rmSync(uploadPath(upload.id), { force: true });
168+ }
169+
170+ // ---------------------------------------------------------------------------
171+ // Manifests and tags
172+
173+ export const MEDIA_TYPES = {
174+ ociManifest: "application/vnd.oci.image.manifest.v1+json",
175+ ociIndex: "application/vnd.oci.image.index.v1+json",
176+ dockerManifest: "application/vnd.docker.distribution.manifest.v2+json",
177+ dockerList: "application/vnd.docker.distribution.manifest.list.v2+json",
178+ };
179+ const INDEX_TYPES = new Set([MEDIA_TYPES.ociIndex, MEDIA_TYPES.dockerList]);
180+ const SCHEMA1_TYPES = new Set([
181+ "application/vnd.docker.distribution.manifest.v1+json",
182+ "application/vnd.docker.distribution.manifest.v1+prettyjws",
183+ ]);
184+
185+ type Descriptor = {
186+ mediaType?: string;
187+ digest?: string;
188+ size?: number;
189+ urls?: string[];
190+ platform?: { os?: string; architecture?: string; variant?: string };
191+ annotations?: Record<string, string>;
192+ };
193+ type ManifestJson = {
194+ mediaType?: string;
195+ artifactType?: string;
196+ config?: Descriptor;
197+ layers?: Descriptor[];
198+ manifests?: Descriptor[];
199+ subject?: Descriptor;
200+ annotations?: Record<string, string>;
201+ };
202+
203+ export type Manifest = { digest: string; mediaType: string; content: Uint8Array };
204+
205+ type ManifestRow = { digest: string; media_type: string; content: Uint8Array; artifact_type: string | null };
206+
207+ const toManifest = (r: ManifestRow): Manifest => ({ digest: r.digest, mediaType: r.media_type, content: r.content });
208+ const parse = (content: Uint8Array) => JSON.parse(Buffer.from(content).toString("utf8")) as ManifestJson;
209+
210+ /** Looks up a manifest by tag or digest. */
211+ export function findManifest(repoId: number, image: string, reference: string): Manifest | null {
212+ const row = (
213+ isDigest(reference)
214+ ? db.prepare("SELECT * FROM registry_manifests WHERE repo_id = ? AND image = ? AND digest = ?").get(repoId, image, reference)
215+ : db
216+ .prepare(
217+ `SELECT m.* FROM registry_tags t JOIN registry_manifests m
218+ ON m.repo_id = t.repo_id AND m.image = t.image AND m.digest = t.digest
219+ WHERE t.repo_id = ? AND t.image = ? AND t.tag = ?`,
220+ )
221+ .get(repoId, image, reference)
222+ ) as ManifestRow | undefined;
223+ return row ? toManifest(row) : null;
224+ }
225+
226+ const isDescriptor = (d: unknown): d is Descriptor & { digest: string } =>
227+ !!d && typeof d === "object" && typeof (d as Descriptor).digest === "string" && isDigest((d as Descriptor).digest!);
228+
229+ /**
230+ * Stores a pushed manifest under its digest and, if `reference` is a tag, points the tag at it.
231+ * The blobs an image manifest references must already be in the repository.
232+ */
233+ export function putManifest(repoId: number, image: string, reference: string, contentType: string | null, content: Uint8Array) {
234+ const algorithm = isDigest(reference) ? reference.split(":")[0] : "sha256";
235+ const digest = `${algorithm}:${createHash(algorithm).update(content).digest("hex")}`;
236+ if (isDigest(reference) && reference !== digest) {
237+ throw new RegistryError(400, "DIGEST_INVALID", "The manifest does not match the digest in the URL.");
238+ }
239+
240+ let json: ManifestJson;
241+ try {
242+ json = JSON.parse(Buffer.from(content).toString("utf8"));
243+ if (!json || typeof json !== "object" || Array.isArray(json)) throw new Error();
244+ } catch {
245+ throw new RegistryError(400, "MANIFEST_INVALID", "The manifest is not valid JSON.");
246+ }
247+ const mediaType = contentType?.split(";")[0].trim() || json.mediaType;
248+ if (!mediaType) throw new RegistryError(400, "MANIFEST_INVALID", "The manifest has no media type.");
249+ if (SCHEMA1_TYPES.has(mediaType)) {
250+ throw new RegistryError(400, "MANIFEST_INVALID", "Docker schema 1 manifests are not supported; push with a current client.");
251+ }
252+ if (json.mediaType && json.mediaType !== mediaType) {
253+ throw new RegistryError(400, "MANIFEST_INVALID", "The Content-Type does not match the manifest's mediaType.");
254+ }
255+
256+ const isIndex = INDEX_TYPES.has(mediaType) || (Array.isArray(json.manifests) && !json.layers);
257+ const children = (isIndex ? (json.manifests ?? []) : []).filter(isDescriptor);
258+ // Foreign layers (with URLs) are fetched from elsewhere and aren't uploaded to the registry.
259+ const blobs = isIndex ? [] : [json.config, ...(json.layers ?? [])].filter(isDescriptor).filter((d) => !d.urls?.length);
260+ for (const blob of blobs) {
261+ if (!db.prepare("SELECT 1 FROM registry_repo_blobs WHERE repo_id = ? AND digest = ?").get(repoId, blob.digest)) {
262+ throw new RegistryError(400, "MANIFEST_BLOB_UNKNOWN", `Blob ${blob.digest} is not in this repository. Push it first.`);
263+ }
264+ }
265+ const size = isIndex ? 0 : [json.config, ...(json.layers ?? [])].reduce((sum, d) => sum + (Number(d?.size) || 0), 0);
266+ const subject = isDescriptor(json.subject) ? json.subject.digest : null;
267+ // As reported by the referrers API: the declared artifact type, else an image manifest's config type.
268+ const artifactType = json.artifactType ?? (isIndex ? null : (json.config?.mediaType ?? null));
269+
270+ transaction(() => {
271+ db.prepare(
272+ `INSERT OR IGNORE INTO registry_manifests (repo_id, image, digest, media_type, content, size, subject, artifact_type, created_at)
273+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
274+ ).run(repoId, image, digest, mediaType, content, size, subject, artifactType, Date.now());
275+ for (const ref of [...children, ...blobs]) {
276+ db.prepare("INSERT OR IGNORE INTO registry_manifest_refs (repo_id, image, manifest_digest, digest) VALUES (?, ?, ?, ?)").run(
277+ repoId,
278+ image,
279+ digest,
280+ ref.digest,
281+ );
282+ }
283+ if (!isDigest(reference)) {
284+ db.prepare(
285+ `INSERT INTO registry_tags (repo_id, image, tag, digest, updated_at) VALUES (?, ?, ?, ?, ?)
286+ ON CONFLICT (repo_id, image, tag) DO UPDATE SET digest = excluded.digest, updated_at = excluded.updated_at`,
287+ ).run(repoId, image, reference, digest, Date.now());
288+ }
289+ });
290+ return { digest, subject };
291+ }
292+
293+ /** Deletes one manifest (and the tags pointing at it). */
294+ export function deleteManifest(repoId: number, image: string, digest: string) {
295+ const res = db.prepare("DELETE FROM registry_manifests WHERE repo_id = ? AND image = ? AND digest = ?").run(repoId, image, digest);
296+ pruneRegistry();
297+ return res.changes > 0;
298+ }
299+
300+ export function deleteTag(repoId: number, image: string, tag: string) {
301+ return db.prepare("DELETE FROM registry_tags WHERE repo_id = ? AND image = ? AND tag = ?").run(repoId, image, tag).changes > 0;
302+ }
303+
304+ /** Tags in lexical order, paginated as in the distribution spec (`n` per page, after `last`). */
305+ export function listTags(repoId: number, image: string, opts: { n?: number; last?: string } = {}) {
306+ const rows = db
307+ .prepare(
308+ `SELECT tag FROM registry_tags WHERE repo_id = ? AND image = ? AND tag > ? ORDER BY tag COLLATE BINARY LIMIT ?`,
309+ )
310+ .all(repoId, image, opts.last ?? "", opts.n ?? -1) as { tag: string }[];
311+ return rows.map((r) => r.tag);
312+ }
313+
314+ /** Manifests whose subject is `digest` (signatures, SBOMs, attestations), for the referrers API. */
315+ export function listReferrers(repoId: number, image: string, digest: string, artifactType: string | null) {
316+ const rows = db
317+ .prepare(
318+ `SELECT digest, media_type, content, artifact_type FROM registry_manifests
319+ WHERE repo_id = ? AND image = ? AND subject = ? AND (? IS NULL OR artifact_type = ?) ORDER BY created_at`,
320+ )
321+ .all(repoId, image, digest, artifactType, artifactType) as ManifestRow[];
322+ return rows.map((r) => {
323+ const { annotations } = parse(r.content);
324+ return {
325+ mediaType: r.media_type,
326+ digest: r.digest,
327+ size: r.content.byteLength,
328+ ...(r.artifact_type ? { artifactType: r.artifact_type } : {}),
329+ ...(annotations ? { annotations } : {}),
330+ };
331+ });
332+ }
333+
334+ // ---------------------------------------------------------------------------
335+ // Garbage collection
336+
337+ /** Blobs pushed this recently are kept even if no manifest references them yet: the push may still be running. */
338+ const BLOB_GRACE_MS = 24 * 3600e3;
339+ const UPLOAD_TTL_MS = 24 * 3600e3;
340+ const PRUNE_INTERVAL_MS = 3600e3;
341+
342+ /**
343+ * Drops blob links no manifest of the repository needs, then deletes blobs no repository links to,
344+ * and abandoned uploads. Runs synchronously so it can't interleave with an upload being stored.
345+ */
346+ export function pruneRegistry() {
347+ g.__registryPrunedAt = Date.now();
348+ const now = Date.now();
349+ const orphans = transaction(() => {
350+ db.prepare(
351+ `DELETE FROM registry_repo_blobs WHERE created_at < ? AND NOT EXISTS (
352+ SELECT 1 FROM registry_manifest_refs r WHERE r.repo_id = registry_repo_blobs.repo_id AND r.digest = registry_repo_blobs.digest)`,
353+ ).run(now - BLOB_GRACE_MS);
354+ return db
355+ .prepare("DELETE FROM registry_blobs WHERE digest NOT IN (SELECT digest FROM registry_repo_blobs) RETURNING digest")
356+ .all() as { digest: string }[];
357+ });
358+ for (const { digest } of orphans) rmSync(blobPath(digest), { force: true });
359+
360+ const stale = db.prepare("DELETE FROM registry_uploads WHERE updated_at < ? RETURNING id").all(now - UPLOAD_TTL_MS) as { id: string }[];
361+ for (const { id } of stale) uploadHashes.delete(id);
362+ // Upload files without a row: abandoned, or their repository was deleted.
363+ const live = new Set((db.prepare("SELECT id FROM registry_uploads").all() as { id: string }[]).map((r) => r.id));
364+ let files: string[] = [];
365+ try {
366+ files = readdirSync(UPLOADS_ROOT);
367+ } catch {}
368+ for (const file of files) {
369+ if (live.has(file)) continue;
370+ try {
371+ if (statSync(uploadPath(file)).mtimeMs < now - 60e3) rmSync(uploadPath(file), { force: true });
372+ } catch {}
373+ }
374+ }
375+
376+ function maybePrune() {
377+ if (Date.now() - (g.__registryPrunedAt ?? 0) > PRUNE_INTERVAL_MS) pruneRegistry();
378+ }
379+
380+ // ---------------------------------------------------------------------------
381+ // Listing for the web UI
382+
383+ export type Platform = { os: string; architecture: string; variant?: string };
384+
385+ export type ImageVersion = {
386+ digest: string;
387+ mediaType: string;
388+ tags: { name: string; updatedAt: number }[];
389+ /** Compressed size of config and layers; for multi-platform images, of the largest platform. */
390+ size: number;
391+ platforms: Platform[];
392+ createdAt: number;
393+ };
394+
395+ export type ImageSummary = { image: string; versions: ImageVersion[]; updatedAt: number };
396+
397+ export function countImages(repoId: number) {
398+ return (db.prepare("SELECT COUNT(DISTINCT image) AS n FROM registry_manifests WHERE repo_id = ?").get(repoId) as { n: number }).n;
399+ }
400+
401+ /**
402+ * The repository's images with their versions: manifests that aren't part of a multi-platform
403+ * index and aren't attached to another manifest (signatures, attestations), newest first.
404+ */
405+ export function listImages(repoId: number): ImageSummary[] {
406+ const manifests = db
407+ .prepare("SELECT image, digest, media_type, content, size, subject, created_at FROM registry_manifests WHERE repo_id = ? ORDER BY created_at DESC")
408+ .all(repoId) as (ManifestRow & { image: string; size: number; subject: string | null; created_at: number })[];
409+ const tags = db.prepare("SELECT image, tag, digest, updated_at FROM registry_tags WHERE repo_id = ? ORDER BY updated_at DESC").all(repoId) as {
410+ image: string;
411+ tag: string;
412+ digest: string;
413+ updated_at: number;
414+ }[];
415+ const refs = db.prepare("SELECT image, digest FROM registry_manifest_refs WHERE repo_id = ?").all(repoId) as { image: string; digest: string }[];
416+
417+ const key = (image: string, digest: string) => `${image}\n${digest}`;
418+ const referenced = new Set(refs.map((r) => key(r.image, r.digest)));
419+ const sizes = new Map(manifests.map((m) => [key(m.image, m.digest), m.size]));
420+ const images = new Map<string, ImageSummary>();
421+
422+ for (const m of manifests) {
423+ const k = key(m.image, m.digest);
424+ const versionTags = tags.filter((t) => t.image === m.image && t.digest === m.digest);
425+ if (versionTags.length === 0 && (m.subject || referenced.has(k))) continue;
426+
427+ const json = parse(m.content);
428+ const platforms = (json.manifests ?? [])
429+ .filter((d) => d.platform?.os && d.platform.os !== "unknown" && d.platform.architecture !== "unknown")
430+ .map((d) => ({ os: d.platform!.os!, architecture: d.platform!.architecture!, ...(d.platform!.variant ? { variant: d.platform!.variant } : {}) }));
431+ const size = json.manifests
432+ ? Math.max(0, ...json.manifests.filter(isDescriptor).map((d) => sizes.get(key(m.image, d.digest)) ?? 0))
433+ : m.size;
434+
435+ const summary = images.get(m.image) ?? { image: m.image, versions: [], updatedAt: 0 };
436+ images.set(m.image, summary);
437+ const updatedAt = Math.max(m.created_at, ...versionTags.map((t) => t.updated_at));
438+ summary.updatedAt = Math.max(summary.updatedAt, updatedAt);
439+ summary.versions.push({
440+ digest: m.digest,
441+ mediaType: m.media_type,
442+ tags: versionTags.map((t) => ({ name: t.tag, updatedAt: t.updated_at })),
443+ size,
444+ platforms,
445+ createdAt: m.created_at,
446+ });
447+ }
448+ for (const summary of images.values()) {
449+ summary.versions.sort((a, b) => Number(b.tags.length > 0) - Number(a.tags.length > 0) || b.createdAt - a.createdAt);
450+ }
451+ return [...images.values()].sort((a, b) => a.image.localeCompare(b.image));
452+ }
453+
454+ /**
455+ * Deletes a version from the web UI: the manifest, the platform manifests only it used, and
456+ * the signatures and attestations attached to any of them.
457+ */
458+ export function deleteImageVersion(repoId: number, image: string, digest: string) {
459+ transaction(() => {
460+ const doomed = new Set([digest]);
461+ const children = db
462+ .prepare("SELECT digest FROM registry_manifest_refs WHERE repo_id = ? AND image = ? AND manifest_digest = ?")
463+ .all(repoId, image, digest) as { digest: string }[];
464+ for (const { digest: child } of children) {
465+ const tagged = db.prepare("SELECT 1 FROM registry_tags WHERE repo_id = ? AND image = ? AND digest = ?").get(repoId, image, child);
466+ const sharedWith = db
467+ .prepare("SELECT 1 FROM registry_manifest_refs WHERE repo_id = ? AND image = ? AND digest = ? AND manifest_digest != ?")
468+ .get(repoId, image, child, digest);
469+ if (!tagged && !sharedWith) doomed.add(child);
470+ }
471+ const attached = db.prepare("SELECT digest FROM registry_manifests WHERE repo_id = ? AND image = ? AND subject = ?");
472+ for (const d of doomed) for (const r of attached.all(repoId, image, d) as { digest: string }[]) doomed.add(r.digest);
473+ const del = db.prepare("DELETE FROM registry_manifests WHERE repo_id = ? AND image = ? AND digest = ?");
474+ for (const d of doomed) del.run(repoId, image, d);
475+ });
476+ pruneRegistry();
477+ }
+12−3lib/repo.tsView File
@@ -58,14 +58,23 @@ export async function getBranchAndPath(repo: Repo, segments: string[]) {
5858 return { branch: resolved.ref, path: resolved.path, exists: true };
5959 }
6060
61- export async function cloneUrls(repo: RepoRecord) {
61+ async function requestHost() {
6262 const h = await headers();
63- const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000";
64- const proto = h.get("x-forwarded-proto") ?? "http";
63+ return { host: h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000", proto: h.get("x-forwarded-proto") ?? "http" };
64+ }
65+
66+ export async function cloneUrls(repo: RepoRecord) {
67+ const { host, proto } = await requestHost();
6568 return {
6669 https: `${proto}://${host}/${repo.owner}/${repo.name}.git`,
6770 ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.git`),
6871 };
6972 }
7073
74+ /** The repository's container image, e.g. vectrum.cc/alice/app. Image names are lowercase. */
75+ export async function imageName(repo: RepoRecord) {
76+ const { host } = await requestHost();
77+ return `${host}/${repo.owner}/${repo.name}`.toLowerCase();
78+ }
79+
7180 export type { RepoRecord };
+3−0lib/repos.tsView File
@@ -3,6 +3,7 @@ import path from "node:path";
33 import { db, transaction } from "./db";
44 import { GitError, initBareRepo, isValidName, lastActivity, pathExists, repoPath } from "./git";
55 import { commitFiles } from "./git-write";
6+ import { pruneRegistry } from "./registry";
67 import { getUserByUsername, type User } from "./users";
78
89 export type Visibility = "public" | "private";
@@ -167,6 +168,8 @@ export async function deleteRepo(repo: RepoRecord) {
167168 transaction(() => {
168169 db.prepare("DELETE FROM repos WHERE id = ?").run(repo.id);
169170 });
171+ // Frees the container images' blobs no other repository uses.
172+ pruneRegistry();
170173 await rm(repo.dir, { recursive: true, force: true });
171174 }
172175
+12−0lib/tokens.tsView File
@@ -117,6 +117,18 @@ export function authenticateToken(secret: string) {
117117 return { user, token: toToken(row) };
118118 }
119119
120+ /**
121+ * Credentials of git and registry requests: HTTP Basic auth with an API token as the password
122+ * (the username is ignored). Null without credentials, "invalid" for an unknown or expired token.
123+ */
124+ export function basicAuthToken(request: Request) {
125+ const header = request.headers.get("authorization");
126+ if (!header?.startsWith("Basic ")) return null;
127+ const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
128+ const password = decoded.slice(decoded.indexOf(":") + 1);
129+ return authenticateToken(password) ?? "invalid";
130+ }
131+
120132 export function tokenCovers(token: ApiToken, repoId: number, needWrite: boolean) {
121133 if (needWrite && token.permission !== "write") return false;
122134 return token.allRepos || token.repos.some((r) => r.id === repoId);
+6−0next.config.tsView File
@@ -3,6 +3,12 @@ import type { NextConfig } from "next";
33 const nextConfig: NextConfig = {
44 // ssh2 has optional native bindings and must be loaded by Node, not bundled.
55 serverExternalPackages: ["ssh2"],
6+ // Container clients request /v2/ and /v2/<name>/blobs/uploads/ with a trailing slash and don't
7+ // follow redirects for uploads, so the built-in redirect is replaced by one that skips /v2.
8+ skipTrailingSlashRedirect: true,
9+ async redirects() {
10+ return [{ source: "/:path((?!v2(?:/|$)).+)/", destination: "/:path", permanent: true }];
11+ },
612 experimental: {
713 authInterrupts: true,
814 serverActions: {