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>
Showing 18 changed files with 1129 additions and 32 deletions.
| @@ -11,6 +11,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 11 | 11 | - **Collaborators**: give other users push access to a repository |
| 12 | 12 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 13 | 13 | - **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 | |
| 14 | 15 | - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored |
| 15 | 16 | - **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 |
| 16 | 17 | - **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 | ||
| 57 | 58 | |
| 58 | 59 | Anonymous users can clone public repositories without a token. |
| 59 | 60 | |
| 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 | + | |
| 60 | 79 | ## Configuration |
| 61 | 80 | |
| 62 | 81 | | Variable | Default | Purpose | |
| @@ -74,8 +93,14 @@ data/ | ||
| 74 | 93 | app.db users, sessions, keys, tokens, repos, issues (SQLite) |
| 75 | 94 | ssh_host_ed25519_key generated on first start |
| 76 | 95 | 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 | |
| 77 | 98 | ``` |
| 78 | 99 | |
| 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 | + | |
| 79 | 104 | ## Security notes |
| 80 | 105 | |
| 81 | 106 | - 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/ | ||
| 91 | 116 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 92 | 117 | - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories |
| 93 | 118 | - `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` | |
| 94 | 120 | - `app/actions/*`: server actions, each re-checking the session and permissions |
| @@ -2,6 +2,7 @@ import Link from "next/link"; | ||
| 2 | 2 | import { RepoTabs } from "@/components/repo-tabs"; |
| 3 | 3 | import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; |
| 4 | 4 | import { openIssueCount } from "@/lib/issues"; |
| 5 | + import { countImages } from "@/lib/registry"; | |
| 5 | 6 | import { getRepo } from "@/lib/repo"; |
| 6 | 7 | |
| 7 | 8 | export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) { |
| @@ -37,7 +38,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 37 | 38 | <VisibilityLabel visibility={repo.visibility} /> |
| 38 | 39 | {repo.access === "write" && <Label tone="accent">Collaborator</Label>} |
| 39 | 40 | </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} /> | |
| 41 | 42 | </div> |
| 42 | 43 | </div> |
| 43 | 44 | <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> |
| @@ -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 & write access as the password. Images can also be named <code className="font-mono text-[12px]">{base}/<name></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 | + } | |
| @@ -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 | + } | |
| @@ -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 }; | |
| @@ -87,6 +87,13 @@ export const RepoIcon = icon( | ||
| 87 | 87 | <path d="M12 22V12" /> |
| 88 | 88 | </>, |
| 89 | 89 | ); |
| 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 | + ); | |
| 90 | 97 | export const CodeIcon = icon( |
| 91 | 98 | <> |
| 92 | 99 | <path d="m16 18 6-6-6-6" /> |
| @@ -3,21 +3,32 @@ | ||
| 3 | 3 | import Link from "next/link"; |
| 4 | 4 | import { usePathname } from "next/navigation"; |
| 5 | 5 | import { useState } from "react"; |
| 6 | - import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon } from "./icons"; | |
| 6 | + import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon } from "./icons"; | |
| 7 | 7 | import { SlidingIndicator, thumbClass } from "./sliding-indicator"; |
| 8 | 8 | |
| 9 | 9 | /** |
| 10 | 10 | * A repository's sections on the switcher track: they are pages, so they are links (not MetalUI Tabs), |
| 11 | 11 | * with the switcher's raised thumb gliding under the current one. |
| 12 | 12 | */ |
| 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 | + }) { | |
| 14 | 24 | const pathname = usePathname(); |
| 15 | 25 | const section = pathname.slice(base.length).split("/")[1] ?? ""; |
| 16 | 26 | 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) }, | |
| 18 | 28 | { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" }, |
| 19 | 29 | { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" }, |
| 20 | 30 | { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" }, |
| 31 | + { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" }, | |
| 21 | 32 | ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []), |
| 22 | 33 | ]; |
| 23 | 34 | |
| @@ -124,6 +124,65 @@ CREATE TABLE IF NOT EXISTS issue_comments ( | ||
| 124 | 124 | body TEXT NOT NULL, |
| 125 | 125 | created_at INTEGER NOT NULL |
| 126 | 126 | ); |
| 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 | + ); | |
| 127 | 186 | `; |
| 128 | 187 | |
| 129 | 188 | function open() { |
| @@ -24,7 +24,8 @@ export function formatDate(ms: number) { | ||
| 24 | 24 | export function formatBytes(n: number) { |
| 25 | 25 | if (n < 1024) return `${n} Bytes`; |
| 26 | 26 | 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`; | |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | 31 | export function shortSha(sha: string) { |
| @@ -3,7 +3,7 @@ import { Readable } from "node:stream"; | ||
| 3 | 3 | import { createGunzip } from "node:zlib"; |
| 4 | 4 | import { normalizeRepoName, spawnService, type GitService } from "./git"; |
| 5 | 5 | import { canWrite, findRepo, markPushed, repoAccess, type RepoRecord } from "./repos"; |
| 6 | - import { authenticateToken, tokenCovers } from "./tokens"; | |
| 6 | + import { basicAuthToken, tokenCovers } from "./tokens"; | |
| 7 | 7 | |
| 8 | 8 | function pktLine(s: string) { |
| 9 | 9 | return (s.length + 4).toString(16).padStart(4, "0") + s; |
| @@ -26,15 +26,6 @@ const unauthorized = (message: string) => | ||
| 26 | 26 | |
| 27 | 27 | const notFound = () => new Response("Repository not found.\n", { status: 404 }); |
| 28 | 28 | |
| 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 | - | |
| 38 | 29 | /** |
| 39 | 30 | * Decides whether a git request may proceed. Anonymous users can clone public |
| 40 | 31 | * repositories; everything else needs a token that covers the repository. |
| @@ -42,7 +33,7 @@ function credentials(request: Request) { | ||
| 42 | 33 | function authorize(request: Request, owner: string, repoParam: string, service: GitService): RepoRecord | Response { |
| 43 | 34 | const repo = findRepo(owner, normalizeRepoName(repoParam)); |
| 44 | 35 | const pushing = service === "git-receive-pack"; |
| 45 | - const creds = credentials(request); | |
| 36 | + const creds = basicAuthToken(request); | |
| 46 | 37 | if (creds === "invalid") return unauthorized("Invalid or expired token."); |
| 47 | 38 | |
| 48 | 39 | if (!creds) { |
| @@ -8,7 +8,7 @@ import { REPOS_ROOT } from "./paths"; | ||
| 8 | 8 | |
| 9 | 9 | const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; |
| 10 | 10 | 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", | |
| 12 | 12 | ]); |
| 13 | 13 | |
| 14 | 14 | export function isValidName(name: string) { |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -58,14 +58,23 @@ export async function getBranchAndPath(repo: Repo, segments: string[]) { | ||
| 58 | 58 | return { branch: resolved.ref, path: resolved.path, exists: true }; |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | - export async function cloneUrls(repo: RepoRecord) { | |
| 61 | + async function requestHost() { | |
| 62 | 62 | 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(); | |
| 65 | 68 | return { |
| 66 | 69 | https: `${proto}://${host}/${repo.owner}/${repo.name}.git`, |
| 67 | 70 | ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.git`), |
| 68 | 71 | }; |
| 69 | 72 | } |
| 70 | 73 | |
| 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 | + | |
| 71 | 80 | export type { RepoRecord }; |
| @@ -3,6 +3,7 @@ import path from "node:path"; | ||
| 3 | 3 | import { db, transaction } from "./db"; |
| 4 | 4 | import { GitError, initBareRepo, isValidName, lastActivity, pathExists, repoPath } from "./git"; |
| 5 | 5 | import { commitFiles } from "./git-write"; |
| 6 | + import { pruneRegistry } from "./registry"; | |
| 6 | 7 | import { getUserByUsername, type User } from "./users"; |
| 7 | 8 | |
| 8 | 9 | export type Visibility = "public" | "private"; |
| @@ -167,6 +168,8 @@ export async function deleteRepo(repo: RepoRecord) { | ||
| 167 | 168 | transaction(() => { |
| 168 | 169 | db.prepare("DELETE FROM repos WHERE id = ?").run(repo.id); |
| 169 | 170 | }); |
| 171 | + // Frees the container images' blobs no other repository uses. | |
| 172 | + pruneRegistry(); | |
| 170 | 173 | await rm(repo.dir, { recursive: true, force: true }); |
| 171 | 174 | } |
| 172 | 175 | |
| @@ -117,6 +117,18 @@ export function authenticateToken(secret: string) { | ||
| 117 | 117 | return { user, token: toToken(row) }; |
| 118 | 118 | } |
| 119 | 119 | |
| 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 | + | |
| 120 | 132 | export function tokenCovers(token: ApiToken, repoId: number, needWrite: boolean) { |
| 121 | 133 | if (needWrite && token.permission !== "write") return false; |
| 122 | 134 | return token.allRepos || token.repos.some((r) => r.id === repoId); |
| @@ -3,6 +3,12 @@ import type { NextConfig } from "next"; | ||
| 3 | 3 | const nextConfig: NextConfig = { |
| 4 | 4 | // ssh2 has optional native bindings and must be loaded by Node, not bundled. |
| 5 | 5 | 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 | + }, | |
| 6 | 12 | experimental: { |
| 7 | 13 | authInterrupts: true, |
| 8 | 14 | serverActions: { |