| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | import type { Account } from "../lib/directory.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import { checkStorageQuota, storageHeadroom } from "../lib/quotas.js"; |
| 7 | import { allows, packageAccess, type PackageAccess, type PackageRole } from "./access.js"; |
| 8 | import { DIGEST_RE, NAME_RE, RegistryStore, TAG_RE, manifestKey, type ManifestRow, type PackageRow, type UploadRow } from "./store.js"; |
| 9 | import { UploadError, digestOf, discard, ingest } from "./upload.js"; |
| 10 | |
| 11 | /** |
| 12 | * Container registry: the OCI distribution API (what `docker login/push/pull`, buildx, |
| 13 | * containerd, podman, crane, oras and helm speak) at https://diffinery.com/v2/. |
| 14 | * |
| 15 | * Images are named diffinery.com/<owner>/<name>, like ghcr.io/<owner>/<name>. Sign in |
| 16 | * with any username and a Diffinery token as the password, or a workflow's GITHUB_TOKEN. |
| 17 | */ |
| 18 | export const registry = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 19 | |
| 20 | export const MANIFEST_TYPES = new Set([ |
| 21 | "application/vnd.oci.image.manifest.v1+json", |
| 22 | "application/vnd.oci.image.index.v1+json", |
| 23 | "application/vnd.docker.distribution.manifest.v2+json", |
| 24 | "application/vnd.docker.distribution.manifest.list.v2+json", |
| 25 | ]); |
| 26 | const INDEX_TYPES = new Set(["application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"]); |
| 27 | const MAX_MANIFEST = 4 * 1024 * 1024; |
| 28 | const API_HEADERS = { "docker-distribution-api-version": "registry/2.0" }; |
| 29 | |
| 30 | export class RegistryError extends Error { |
| 31 | constructor( |
| 32 | public status: number, |
| 33 | public code: string, |
| 34 | message: string, |
| 35 | public headers: Record<string, string> = {}, |
| 36 | ) { |
| 37 | super(message); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | function errorJson(status: number, code: string, message: string, headers: Record<string, string> = {}): Response { |
| 42 | return new Response(JSON.stringify({ errors: [{ code, message, detail: null }] }), { status, headers: { "content-type": "application/json", ...API_HEADERS, ...headers } }); |
| 43 | } |
| 44 | |
| 45 | const challenge = (c: AppContext) => ({ "www-authenticate": `Basic realm="${new URL(c.env.PUBLIC_URL).host}", charset="UTF-8"` }); |
| 46 | |
| 47 | const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join(""); |
| 48 | |
| 49 | // ---------------------------------------------------------------- routing |
| 50 | |
| 51 | registry.all("/v2", (c) => c.redirect("/v2/", 301)); |
| 52 | |
| 53 | registry.all("/v2/*", async (c) => { |
| 54 | try { |
| 55 | const res = await route(c); |
| 56 | for (const [k, v] of Object.entries(API_HEADERS)) res.headers.set(k, v); |
| 57 | return res; |
| 58 | } catch (e) { |
| 59 | if (e instanceof RegistryError) return errorJson(e.status, e.code, e.message, e.status === 401 ? { ...challenge(c), ...e.headers } : e.headers); |
| 60 | if (e instanceof UploadError) return errorJson(400, "BLOB_UPLOAD_INVALID", e.message); |
| 61 | if (e instanceof HttpError) return errorJson(e.status, e.status === 401 ? "UNAUTHORIZED" : e.status === 403 ? "DENIED" : e.status === 404 ? "NAME_UNKNOWN" : "UNSUPPORTED", e.detail, e.status === 401 ? challenge(c) : {}); |
| 62 | console.error("registry error", c.req.method, c.req.path, e); |
| 63 | return errorJson(500, "UNKNOWN", "internal error"); |
| 64 | } |
| 65 | }); |
| 66 | |
| 67 | async function route(c: AppContext): Promise<Response> { |
| 68 | const path = c.req.path; |
| 69 | const method = c.req.method; |
| 70 | if (path === "/v2/") { |
| 71 | if (c.get("principal").type === "anonymous") throw new RegistryError(401, "UNAUTHORIZED", "sign in with a Diffinery token: docker login " + new URL(c.env.PUBLIC_URL).host); |
| 72 | return c.json({}); |
| 73 | } |
| 74 | if (path === "/v2/_catalog") return catalog(c); |
| 75 | let m: RegExpExecArray | null; |
| 76 | if ((m = /^\/v2\/(.+)\/tags\/list$/.exec(path))) return tagsList(c, m[1]!); |
| 77 | if ((m = /^\/v2\/(.+)\/manifests\/([^/]+)$/.exec(path))) { |
| 78 | if (method === "GET" || method === "HEAD") return getManifest(c, m[1]!, m[2]!); |
| 79 | if (method === "PUT") return putManifest(c, m[1]!, m[2]!); |
| 80 | if (method === "DELETE") return deleteManifest(c, m[1]!, m[2]!); |
| 81 | } |
| 82 | if ((m = /^\/v2\/(.+)\/referrers\/([^/]+)$/.exec(path)) && method === "GET") return referrers(c, m[1]!, m[2]!); |
| 83 | if ((m = /^\/v2\/(.+)\/blobs\/uploads\/?$/.exec(path)) && method === "POST") return startUpload(c, m[1]!); |
| 84 | if ((m = /^\/v2\/(.+)\/blobs\/uploads\/([0-9a-f]{32})$/.exec(path))) { |
| 85 | if (method === "GET") return uploadStatus(c, m[1]!, m[2]!); |
| 86 | if (method === "PATCH") return patchUpload(c, m[1]!, m[2]!); |
| 87 | if (method === "PUT") return putUpload(c, m[1]!, m[2]!); |
| 88 | if (method === "DELETE") return cancelUpload(c, m[1]!, m[2]!); |
| 89 | } |
| 90 | if ((m = /^\/v2\/(.+)\/blobs\/([^/]+)$/.exec(path))) { |
| 91 | if (method === "GET" || method === "HEAD") return getBlob(c, m[1]!, m[2]!); |
| 92 | if (method === "DELETE") return deleteBlob(c, m[1]!, m[2]!); |
| 93 | } |
| 94 | throw new RegistryError(404, "UNSUPPORTED", `${method} ${path} is not part of the registry API`); |
| 95 | } |
| 96 | |
| 97 | // ---------------------------------------------------------------- names and access |
| 98 | |
| 99 | interface Target { |
| 100 | /** The name as the client wrote it (owner/name), for Location headers. */ |
| 101 | full: string; |
| 102 | owner: Account; |
| 103 | name: string; |
| 104 | pkg: PackageRow | null; |
| 105 | access: PackageAccess; |
| 106 | store: RegistryStore; |
| 107 | } |
| 108 | |
| 109 | /** Splits <owner>/<name>, loads the package and checks the caller may `need` it. */ |
| 110 | async function target(c: AppContext, full: string, need: PackageRole): Promise<Target> { |
| 111 | const lower = full.toLowerCase(); |
| 112 | const slash = lower.indexOf("/"); |
| 113 | let name = slash > 0 ? lower.slice(slash + 1) : ""; |
| 114 | if (slash <= 0 || !NAME_RE.test(name) || lower.length > 255) { |
| 115 | throw new RegistryError(400, "NAME_INVALID", `invalid image name "${full}": use ${new URL(c.env.PUBLIC_URL).host}/<owner>/<name> with lowercase letters, digits, '.', '_', '-' and '/'`); |
| 116 | } |
| 117 | const store = new RegistryStore(c.env); |
| 118 | const dir = c.get("dir"); |
| 119 | const ownerLogin = lower.slice(0, slash); |
| 120 | let owner = await dir.accountByLogin(ownerLogin); |
| 121 | let pkg = owner && owner.kind !== "agent" ? await store.package(owner.id, name) : null; |
| 122 | // An image that moved with its repository to another owner, or one under a renamed owner, answers |
| 123 | // under its old name too. Access is the moved package's, so the old name never shows a private one. |
| 124 | let moved = false; |
| 125 | if (!pkg) { |
| 126 | const found = await store.movedPackage(ownerLogin, name); |
| 127 | const renamed = found ? null : await dir.renamedAccount(ownerLogin); |
| 128 | const to = found ? await dir.account(found.owner_id) : renamed; |
| 129 | if (to) { |
| 130 | moved = true; |
| 131 | owner = to; |
| 132 | pkg = found ?? (await store.package(to.id, name)); |
| 133 | } |
| 134 | } |
| 135 | if (!owner || owner.kind === "agent") { |
| 136 | if (c.get("principal").type === "anonymous") throw new RegistryError(401, "UNAUTHORIZED", "authentication required"); |
| 137 | throw new RegistryError(404, "NAME_UNKNOWN", `no user or organization named ${ownerLogin}`); |
| 138 | } |
| 139 | if (moved && pkg) name = pkg.name; |
| 140 | const access = await packageAccess(c, owner, name, pkg); |
| 141 | if (!allows(access.role, need)) { |
| 142 | const anon = c.get("principal").type === "anonymous"; |
| 143 | if (anon) throw new RegistryError(401, "UNAUTHORIZED", "authentication required"); |
| 144 | if (moved && !allows(access.role, "pull")) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${lower} not found`); |
| 145 | if (!allows(access.role, "pull")) { |
| 146 | if (need === "pull" || pkg) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${owner.login}/${name} not found${access.hint ? ` (${access.hint})` : ""}`); |
| 147 | throw new RegistryError(403, "DENIED", `you can't create ${owner.login}/${name}${access.hint ? `: ${access.hint}` : ""}`); |
| 148 | } |
| 149 | throw new RegistryError(403, "DENIED", `${need === "admin" ? "deleting from" : "pushing to"} ${owner.login}/${name} is not allowed${access.hint ? `: ${access.hint}` : ""}`); |
| 150 | } |
| 151 | return { full, owner, name, pkg, access, store }; |
| 152 | } |
| 153 | |
| 154 | /** The package, created on first push (linked to a repository when the access check found one). */ |
| 155 | async function ensurePackage(c: AppContext, t: Target): Promise<PackageRow> { |
| 156 | if (t.pkg) return t.pkg; |
| 157 | const pkg = await t.store.createPackage(t.owner.id, t.name, t.access.linkRepo?.id ?? null, t.access.actor?.id ?? "unknown"); |
| 158 | c.executionCtx.waitUntil( |
| 159 | c |
| 160 | .get("dir") |
| 161 | .audit({ actorId: t.access.actor?.id, actorLogin: t.access.actor?.login, action: "package.create", target: `${t.owner.login}/${t.name}`, detail: { repository: t.access.linkRepo ? `${t.access.linkRepo.owner_login}/${t.access.linkRepo.name}` : null } }) |
| 162 | .catch((e) => console.error("audit failed", e)), |
| 163 | ); |
| 164 | t.pkg = pkg; |
| 165 | return pkg; |
| 166 | } |
| 167 | |
| 168 | function requirePackage(t: Target): PackageRow { |
| 169 | if (!t.pkg) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${t.owner.login}/${t.name} not found`); |
| 170 | return t.pkg; |
| 171 | } |
| 172 | |
| 173 | // ---------------------------------------------------------------- blobs |
| 174 | |
| 175 | async function getBlob(c: AppContext, full: string, digest: string): Promise<Response> { |
| 176 | if (!DIGEST_RE.test(digest)) throw new RegistryError(400, "DIGEST_INVALID", "only sha256 digests are supported"); |
| 177 | const t = await target(c, full, "pull"); |
| 178 | const pkg = requirePackage(t); |
| 179 | const blob = await t.store.packageBlob(pkg.id, digest); |
| 180 | if (!blob) { |
| 181 | // Manifests may be fetched through the blob endpoint too. |
| 182 | const man = await t.store.manifest(pkg.id, digest); |
| 183 | if (man) return manifestResponse(c, man, c.req.method === "HEAD"); |
| 184 | throw new RegistryError(404, "BLOB_UNKNOWN", `blob ${digest} not found`); |
| 185 | } |
| 186 | const headers: Record<string, string> = { "docker-content-digest": digest, "content-type": "application/octet-stream", etag: `"${digest}"`, "accept-ranges": "bytes", "cache-control": "private, max-age=31536000, immutable" }; |
| 187 | if (c.req.method === "HEAD") return new Response(null, { status: 200, headers: { ...headers, "content-length": String(blob.size) } }); |
| 188 | const range = parseRange(c.req.header("range"), blob.size); |
| 189 | if (range === "invalid") return new Response(null, { status: 416, headers: { "content-range": `bytes */${blob.size}` } }); |
| 190 | const obj = await c.env.BUCKET.get(blob.r2_key, range ? { range: { offset: range.start, length: range.end - range.start + 1 } } : undefined); |
| 191 | if (!obj) throw new RegistryError(404, "BLOB_UNKNOWN", `blob ${digest} is missing from storage`); |
| 192 | if (range) return new Response(obj.body, { status: 206, headers: { ...headers, "content-length": String(range.end - range.start + 1), "content-range": `bytes ${range.start}-${range.end}/${blob.size}` } }); |
| 193 | return new Response(obj.body, { status: 200, headers: { ...headers, "content-length": String(blob.size) } }); |
| 194 | } |
| 195 | |
| 196 | function parseRange(h: string | undefined, size: number): { start: number; end: number } | null | "invalid" { |
| 197 | if (!h) return null; |
| 198 | const m = /^bytes=(\d*)-(\d*)$/.exec(h.trim()); |
| 199 | if (!m || (!m[1] && !m[2])) return null; |
| 200 | let start: number; |
| 201 | let end: number; |
| 202 | if (!m[1]) { |
| 203 | start = Math.max(0, size - Number(m[2])); |
| 204 | end = size - 1; |
| 205 | } else { |
| 206 | start = Number(m[1]); |
| 207 | end = m[2] ? Math.min(Number(m[2]), size - 1) : size - 1; |
| 208 | } |
| 209 | if (start >= size || end < start) return "invalid"; |
| 210 | return { start, end }; |
| 211 | } |
| 212 | |
| 213 | async function deleteBlob(c: AppContext, full: string, digest: string): Promise<Response> { |
| 214 | if (!DIGEST_RE.test(digest)) throw new RegistryError(400, "DIGEST_INVALID", "only sha256 digests are supported"); |
| 215 | const t = await target(c, full, "admin"); |
| 216 | const pkg = requirePackage(t); |
| 217 | if (!(await t.store.packageBlob(pkg.id, digest))) throw new RegistryError(404, "BLOB_UNKNOWN", `blob ${digest} not found`); |
| 218 | await t.store.unlinkBlob(pkg.id, digest); |
| 219 | return new Response(null, { status: 202 }); |
| 220 | } |
| 221 | |
| 222 | const uploadUrl = (t: Target, id: string) => `/v2/${t.full}/blobs/uploads/${id}`; |
| 223 | |
| 224 | function uploadHeaders(t: Target, u: UploadRow): Record<string, string> { |
| 225 | // Range can't tell an empty upload from a one-byte one, so the size is spelled out too. |
| 226 | return { location: uploadUrl(t, u.id), range: `0-${Math.max(0, u.size - 1)}`, "docker-upload-uuid": u.id, "diffinery-upload-size": String(u.size), "content-length": "0" }; |
| 227 | } |
| 228 | |
| 229 | /** Refuse a blob past the owner's storage quota (lib/quotas.ts): `sofar` bytes uploaded, plus this request's. */ |
| 230 | function checkStorage(c: AppContext, t: Target, sofar = 0): Promise<void> { |
| 231 | return checkStorageQuota(c.env, t.owner.id, t.owner.login, sofar + (Number(c.req.header("content-length")) || 0)); |
| 232 | } |
| 233 | |
| 234 | /** The largest blob Diffinery stores (GitHub's container registry takes layers up to 10 GB). */ |
| 235 | export const MAX_BLOB_BYTES = 10 * 1024 ** 3; |
| 236 | |
| 237 | /** How big this upload may grow: the blob cap, and what is left of the owner's storage quota. */ |
| 238 | async function uploadLimit(c: AppContext, t: Target): Promise<{ bytes: number; error: () => Error }> { |
| 239 | const headroom = await storageHeadroom(c.env, t.owner.id); |
| 240 | if (headroom < MAX_BLOB_BYTES) return { bytes: headroom, error: () => new HttpError(403, `${t.owner.login} has reached its storage limit (git objects, Git LFS objects, release assets and packages); delete some or ask a site administrator to raise it`) }; |
| 241 | return { bytes: MAX_BLOB_BYTES, error: () => new UploadError(`a blob may be at most ${MAX_BLOB_BYTES / 1024 ** 3} GiB`) }; |
| 242 | } |
| 243 | |
| 244 | /** Append this request's body within uploadLimit. Past it, ingest has thrown the bytes away and the upload is gone too. */ |
| 245 | async function ingestCapped(c: AppContext, t: Target, u: UploadRow, final: boolean): Promise<UploadRow> { |
| 246 | const limit = await uploadLimit(c, t); |
| 247 | let over = false; |
| 248 | try { |
| 249 | return await ingest(c.env, u, c.req.raw.body, final, { bytes: limit.bytes, error: () => { over = true; return limit.error(); } }); |
| 250 | } catch (e) { |
| 251 | if (over) await t.store.deleteUpload(u.id); |
| 252 | throw e; |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | async function startUpload(c: AppContext, full: string): Promise<Response> { |
| 257 | const t = await target(c, full, "push"); |
| 258 | await checkStorage(c, t); |
| 259 | const pkg = await ensurePackage(c, t); |
| 260 | const mount = c.req.query("mount"); |
| 261 | const from = c.req.query("from"); |
| 262 | if (mount && DIGEST_RE.test(mount)) { |
| 263 | // Cross-repository mount: reuse a blob the caller can already pull from another package. |
| 264 | if (await t.store.packageBlob(pkg.id, mount)) return blobCreated(t, mount); |
| 265 | if (from) { |
| 266 | try { |
| 267 | const src = await target(c, from, "pull"); |
| 268 | if (src.pkg && (await t.store.packageBlob(src.pkg.id, mount))) { |
| 269 | await t.store.linkBlob(pkg.id, mount); |
| 270 | return blobCreated(t, mount); |
| 271 | } |
| 272 | } catch (e) { |
| 273 | if (!(e instanceof RegistryError)) throw e; |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | const u = await t.store.createUpload(pkg.id); |
| 278 | const digest = c.req.query("digest"); |
| 279 | if (digest) { |
| 280 | // Monolithic upload: the whole blob in the POST body. |
| 281 | if (!DIGEST_RE.test(digest)) throw new RegistryError(400, "DIGEST_INVALID", "only sha256 digests are supported"); |
| 282 | return finishUpload(c, t, pkg, await ingestCapped(c, t, u, true), digest); |
| 283 | } |
| 284 | return new Response(null, { status: 202, headers: uploadHeaders(t, u) }); |
| 285 | } |
| 286 | |
| 287 | async function loadUpload(c: AppContext, full: string, id: string, need: PackageRole = "push"): Promise<{ t: Target; pkg: PackageRow; u: UploadRow }> { |
| 288 | const t = await target(c, full, need); |
| 289 | const pkg = requirePackage(t); |
| 290 | const u = await t.store.upload(id); |
| 291 | if (!u || u.package_id !== pkg.id) throw new RegistryError(404, "BLOB_UPLOAD_UNKNOWN", "upload not found; it may have expired"); |
| 292 | return { t, pkg, u }; |
| 293 | } |
| 294 | |
| 295 | async function uploadStatus(c: AppContext, full: string, id: string): Promise<Response> { |
| 296 | const { t, u } = await loadUpload(c, full, id); |
| 297 | return new Response(null, { status: 204, headers: uploadHeaders(t, u) }); |
| 298 | } |
| 299 | |
| 300 | function checkContentRange(c: AppContext, t: Target, u: UploadRow): void { |
| 301 | const cr = c.req.header("content-range"); |
| 302 | if (!cr) return; |
| 303 | const m = /^(?:bytes[ =])?(\d+)-(\d+)(?:\/\d+|\/\*)?$/.exec(cr.trim()); |
| 304 | if (!m || Number(m[1]) !== u.size) throw new RegistryError(416, "BLOB_UPLOAD_INVALID", `chunk must start at byte ${u.size}`, uploadHeaders(t, u)); |
| 305 | } |
| 306 | |
| 307 | async function patchUpload(c: AppContext, full: string, id: string): Promise<Response> { |
| 308 | const { t, u } = await loadUpload(c, full, id); |
| 309 | checkContentRange(c, t, u); |
| 310 | await checkStorage(c, t, u.size); |
| 311 | const next = await ingestCapped(c, t, u, false); |
| 312 | await t.store.saveUpload(next); |
| 313 | return new Response(null, { status: 202, headers: uploadHeaders(t, next) }); |
| 314 | } |
| 315 | |
| 316 | async function putUpload(c: AppContext, full: string, id: string): Promise<Response> { |
| 317 | const { t, pkg, u } = await loadUpload(c, full, id); |
| 318 | const digest = c.req.query("digest"); |
| 319 | if (!digest || !DIGEST_RE.test(digest)) throw new RegistryError(400, "DIGEST_INVALID", "the digest query parameter must be a sha256 digest"); |
| 320 | if (c.req.raw.body) checkContentRange(c, t, u); |
| 321 | await checkStorage(c, t, u.size); |
| 322 | return finishUpload(c, t, pkg, await ingestCapped(c, t, u, true), digest); |
| 323 | } |
| 324 | |
| 325 | async function cancelUpload(c: AppContext, full: string, id: string): Promise<Response> { |
| 326 | const { t, u } = await loadUpload(c, full, id); |
| 327 | await discard(c.env, u); |
| 328 | await t.store.deleteUpload(u.id); |
| 329 | return new Response(null, { status: 204 }); |
| 330 | } |
| 331 | |
| 332 | /** Verifies a completed upload against its digest and adds it to the package. */ |
| 333 | async function finishUpload(c: AppContext, t: Target, pkg: PackageRow, u: UploadRow, expected: string): Promise<Response> { |
| 334 | let actual = u.digest; |
| 335 | if (!actual) actual = (await digestOf(c.env, u.r2_key))?.digest ?? null; |
| 336 | if (actual !== expected) { |
| 337 | await discard(c.env, u); |
| 338 | await t.store.deleteUpload(u.id); |
| 339 | throw new RegistryError(400, "DIGEST_INVALID", `the uploaded content has digest ${actual ?? "unknown"}, not ${expected}`); |
| 340 | } |
| 341 | await t.store.addBlob(expected, u.size, u.r2_key); |
| 342 | await t.store.linkBlob(pkg.id, expected); |
| 343 | // Linked now, so cleanup can't drop the index row; if another upload already stored these bytes, ours go. |
| 344 | const stored = await t.store.blob(expected); |
| 345 | if (!stored) await t.store.addBlob(expected, u.size, u.r2_key); |
| 346 | else if (stored.r2_key !== u.r2_key) await c.env.BUCKET.delete(u.r2_key); |
| 347 | await t.store.deleteUpload(u.id); |
| 348 | return blobCreated(t, expected); |
| 349 | } |
| 350 | |
| 351 | function blobCreated(t: Target, digest: string): Response { |
| 352 | return new Response(null, { status: 201, headers: { location: `/v2/${t.full}/blobs/${digest}`, "docker-content-digest": digest, "content-length": "0" } }); |
| 353 | } |
| 354 | |
| 355 | // ---------------------------------------------------------------- manifests |
| 356 | |
| 357 | async function resolveReference(t: Target, pkg: PackageRow, reference: string): Promise<ManifestRow | null> { |
| 358 | if (DIGEST_RE.test(reference)) return t.store.manifest(pkg.id, reference); |
| 359 | if (!TAG_RE.test(reference)) throw new RegistryError(400, "MANIFEST_INVALID", `invalid tag "${reference}"`); |
| 360 | const digest = await t.store.tagDigest(pkg.id, reference); |
| 361 | return digest ? t.store.manifest(pkg.id, digest) : null; |
| 362 | } |
| 363 | |
| 364 | async function manifestResponse(c: AppContext, m: ManifestRow, head: boolean): Promise<Response> { |
| 365 | const headers: Record<string, string> = { "content-type": m.media_type, "docker-content-digest": m.digest, etag: `"${m.digest}"`, "content-length": String(m.size) }; |
| 366 | if (head) return new Response(null, { status: 200, headers }); |
| 367 | const obj = await c.env.BUCKET.get(manifestKey(m.digest)); |
| 368 | if (!obj) throw new RegistryError(404, "MANIFEST_UNKNOWN", `manifest ${m.digest} is missing from storage`); |
| 369 | return new Response(obj.body, { status: 200, headers }); |
| 370 | } |
| 371 | |
| 372 | async function getManifest(c: AppContext, full: string, reference: string): Promise<Response> { |
| 373 | const t = await target(c, full, "pull"); |
| 374 | const pkg = requirePackage(t); |
| 375 | const m = await resolveReference(t, pkg, reference); |
| 376 | if (!m) throw new RegistryError(404, "MANIFEST_UNKNOWN", `manifest ${reference} not found in ${t.owner.login}/${t.name}`); |
| 377 | return manifestResponse(c, m, c.req.method === "HEAD"); |
| 378 | } |
| 379 | |
| 380 | interface Descriptor { |
| 381 | mediaType?: string; |
| 382 | digest?: string; |
| 383 | size?: number; |
| 384 | urls?: string[]; |
| 385 | artifactType?: string; |
| 386 | annotations?: Record<string, string>; |
| 387 | platform?: { os?: string; architecture?: string; variant?: string }; |
| 388 | } |
| 389 | |
| 390 | interface ManifestJson { |
| 391 | schemaVersion?: number; |
| 392 | mediaType?: string; |
| 393 | artifactType?: string; |
| 394 | config?: Descriptor; |
| 395 | layers?: Descriptor[]; |
| 396 | blobs?: Descriptor[]; |
| 397 | manifests?: Descriptor[]; |
| 398 | subject?: Descriptor; |
| 399 | annotations?: Record<string, string>; |
| 400 | } |
| 401 | |
| 402 | const platformName = (p?: Descriptor["platform"]) => (p?.os && p.architecture ? `${p.os}/${p.architecture}${p.variant ? `/${p.variant}` : ""}` : null); |
| 403 | |
| 404 | async function putManifest(c: AppContext, full: string, reference: string): Promise<Response> { |
| 405 | const t = await target(c, full, "push"); |
| 406 | const isDigest = DIGEST_RE.test(reference); |
| 407 | if (!isDigest && !TAG_RE.test(reference)) throw new RegistryError(400, "MANIFEST_INVALID", `invalid tag "${reference}"`); |
| 408 | const declared = Number(c.req.header("content-length") ?? 0); |
| 409 | if (declared > MAX_MANIFEST) throw new RegistryError(413, "MANIFEST_INVALID", "manifests are limited to 4 MiB"); |
| 410 | const bytes = new Uint8Array(await c.req.arrayBuffer()); |
| 411 | if (bytes.length > MAX_MANIFEST) throw new RegistryError(413, "MANIFEST_INVALID", "manifests are limited to 4 MiB"); |
| 412 | const digest = `sha256:${hex(await crypto.subtle.digest("SHA-256", bytes))}`; |
| 413 | if (isDigest && reference !== digest) throw new RegistryError(400, "DIGEST_INVALID", `the manifest's digest is ${digest}, not ${reference}`); |
| 414 | let json: ManifestJson; |
| 415 | try { |
| 416 | json = JSON.parse(new TextDecoder().decode(bytes)) as ManifestJson; |
| 417 | } catch { |
| 418 | throw new RegistryError(400, "MANIFEST_INVALID", "the manifest is not valid JSON"); |
| 419 | } |
| 420 | if (typeof json !== "object" || json === null || json.schemaVersion !== 2) throw new RegistryError(400, "MANIFEST_INVALID", "only schemaVersion 2 manifests (OCI or Docker v2) are supported"); |
| 421 | const header = (c.req.header("content-type") ?? "").split(";")[0]!.trim(); |
| 422 | const mediaType = json.mediaType || (MANIFEST_TYPES.has(header) ? header : json.manifests ? "application/vnd.oci.image.index.v1+json" : "application/vnd.oci.image.manifest.v1+json"); |
| 423 | if (!MANIFEST_TYPES.has(mediaType)) throw new RegistryError(400, "MANIFEST_INVALID", `unsupported manifest media type ${mediaType}`); |
| 424 | if (json.mediaType && MANIFEST_TYPES.has(header) && header !== json.mediaType) throw new RegistryError(400, "MANIFEST_INVALID", `Content-Type ${header} does not match the manifest's mediaType ${json.mediaType}`); |
| 425 | |
| 426 | const pkg = await ensurePackage(c, t); |
| 427 | const isIndex = INDEX_TYPES.has(mediaType); |
| 428 | let totalSize = 0; |
| 429 | let platforms: Array<{ digest?: string; platform: string | null }> = []; |
| 430 | const blobRefs: string[] = []; |
| 431 | if (isIndex) { |
| 432 | const children = (json.manifests ?? []).filter((d) => d.digest && DIGEST_RE.test(d.digest)); |
| 433 | if (children.length !== (json.manifests ?? []).length) throw new RegistryError(400, "MANIFEST_INVALID", "every entry in manifests needs a sha256 digest"); |
| 434 | const have = await t.store.manifestSizes(pkg.id, children.map((d) => d.digest!)); |
| 435 | const missing = children.find((d) => !have.has(d.digest!)); |
| 436 | if (missing) throw new RegistryError(400, "MANIFEST_BLOB_UNKNOWN", `manifest ${missing.digest} must be pushed before the index that lists it`); |
| 437 | platforms = children.map((d) => ({ digest: d.digest, platform: platformName(d.platform) })); |
| 438 | // What pulling every platform downloads; attestations and other artifacts don't count. |
| 439 | totalSize = children.filter((d) => platformName(d.platform) !== "unknown/unknown").reduce((n, d) => n + (have.get(d.digest!) ?? 0), 0); |
| 440 | } else { |
| 441 | const descs = [...(json.config ? [json.config] : []), ...(json.layers ?? []), ...(json.blobs ?? [])]; |
| 442 | if (!json.config?.digest) throw new RegistryError(400, "MANIFEST_INVALID", "an image manifest needs a config descriptor"); |
| 443 | const local = descs.filter((d) => !d.urls?.length); |
| 444 | for (const d of local) if (!d.digest || !DIGEST_RE.test(d.digest)) throw new RegistryError(400, "MANIFEST_INVALID", "every config and layer needs a sha256 digest"); |
| 445 | const digests = [...new Set(local.map((d) => d.digest!))]; |
| 446 | const have = await t.store.packageBlobs(pkg.id, digests); |
| 447 | const missing = digests.find((d) => !have.has(d)); |
| 448 | if (missing) throw new RegistryError(400, "MANIFEST_BLOB_UNKNOWN", `blob ${missing} must be uploaded before the manifest that uses it`); |
| 449 | blobRefs.push(...digests); |
| 450 | totalSize = descs.reduce((n, d) => n + (d.size ?? 0), 0); |
| 451 | const config = await readConfig(c, json.config); |
| 452 | if (config) { |
| 453 | platforms = [{ platform: platformName(config) }]; |
| 454 | await linkBySourceLabel(c, t, pkg, config.config?.Labels ?? {}); |
| 455 | const desc = config.config?.Labels?.["org.opencontainers.image.description"]; |
| 456 | if (desc && !pkg.description) await t.store.updatePackage(pkg.id, { description: desc.slice(0, 500) }); |
| 457 | } |
| 458 | } |
| 459 | const desc = json.annotations?.["org.opencontainers.image.description"]; |
| 460 | if (desc && !pkg.description) await t.store.updatePackage(pkg.id, { description: desc.slice(0, 500) }); |
| 461 | if (json.annotations?.["org.opencontainers.image.source"]) await linkBySourceLabel(c, t, pkg, json.annotations); |
| 462 | |
| 463 | const key = manifestKey(digest); |
| 464 | if (!(await c.env.BUCKET.head(key))) await c.env.BUCKET.put(key, bytes, { httpMetadata: { contentType: mediaType } }); |
| 465 | const subject = json.subject?.digest && DIGEST_RE.test(json.subject.digest) ? json.subject.digest : null; |
| 466 | await t.store.putManifest( |
| 467 | { |
| 468 | package_id: pkg.id, |
| 469 | digest, |
| 470 | media_type: mediaType, |
| 471 | artifact_type: json.artifactType ?? (json.config?.mediaType && json.config.mediaType !== "application/vnd.oci.image.config.v1+json" && json.config.mediaType !== "application/vnd.docker.container.image.v1+json" ? json.config.mediaType : null), |
| 472 | subject, |
| 473 | annotations: json.annotations ? JSON.stringify(json.annotations) : null, |
| 474 | size: bytes.length, |
| 475 | total_size: totalSize, |
| 476 | platforms: platforms.length ? JSON.stringify(platforms) : null, |
| 477 | pushed_by: t.access.actor?.login ?? null, |
| 478 | created_at: new Date().toISOString(), |
| 479 | }, |
| 480 | isDigest ? null : reference, |
| 481 | blobRefs, |
| 482 | ); |
| 483 | if (!isDigest) { |
| 484 | c.executionCtx.waitUntil( |
| 485 | c |
| 486 | .get("dir") |
| 487 | .audit({ actorId: t.access.actor?.id, actorLogin: t.access.actor?.login, action: "package.push", target: `${t.owner.login}/${t.name}:${reference}`, detail: { digest } }) |
| 488 | .catch((e) => console.error("audit failed", e)), |
| 489 | ); |
| 490 | } |
| 491 | const headers: Record<string, string> = { location: `/v2/${t.full}/manifests/${digest}`, "docker-content-digest": digest, "content-length": "0" }; |
| 492 | if (subject) headers["oci-subject"] = subject; |
| 493 | return new Response(null, { status: 201, headers }); |
| 494 | } |
| 495 | |
| 496 | interface ImageConfig { |
| 497 | os?: string; |
| 498 | architecture?: string; |
| 499 | variant?: string; |
| 500 | config?: { Labels?: Record<string, string> | null }; |
| 501 | } |
| 502 | |
| 503 | /** The image config (platform and labels), when it's a small JSON blob in this registry. */ |
| 504 | async function readConfig(c: AppContext, d: Descriptor): Promise<ImageConfig | null> { |
| 505 | if (d.mediaType && d.mediaType !== "application/vnd.oci.image.config.v1+json" && d.mediaType !== "application/vnd.docker.container.image.v1+json") return null; |
| 506 | if (!d.digest || (d.size ?? 0) > 1024 * 1024) return null; |
| 507 | const blob = await new RegistryStore(c.env).blob(d.digest); |
| 508 | if (!blob) return null; |
| 509 | const obj = await c.env.BUCKET.get(blob.r2_key); |
| 510 | if (!obj) return null; |
| 511 | try { |
| 512 | const cfg = (await obj.json()) as ImageConfig; |
| 513 | return { os: cfg.os, architecture: cfg.architecture, variant: cfg.variant, config: { Labels: cfg.config?.Labels ?? {} } }; |
| 514 | } catch { |
| 515 | return null; |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | /** |
| 520 | * Links an unlinked package to the repository its org.opencontainers.image.source label |
| 521 | * names (a Diffinery URL, or a GitHub URL of a repository moved here), when the pusher |
| 522 | * can write to that repository. Like ghcr.io, the package then shares its permissions. |
| 523 | */ |
| 524 | async function linkBySourceLabel(c: AppContext, t: Target, pkg: PackageRow, labels: Record<string, string>): Promise<void> { |
| 525 | if (pkg.repo_id) return; |
| 526 | const src = labels["org.opencontainers.image.source"]; |
| 527 | if (!src) return; |
| 528 | let url: URL; |
| 529 | try { |
| 530 | url = new URL(src); |
| 531 | } catch { |
| 532 | return; |
| 533 | } |
| 534 | const ours = new URL(c.env.PUBLIC_URL).host; |
| 535 | if (url.host !== ours && url.host !== "github.com") return; |
| 536 | const [owner, repoName] = url.pathname.replace(/^\/+/, "").replace(/\.git$/, "").split("/"); |
| 537 | if (!owner || !repoName || owner.toLowerCase() !== t.owner.login.toLowerCase()) return; |
| 538 | const repo = await c.get("dir").repo(t.owner.login, repoName); |
| 539 | if (!repo) return; |
| 540 | const p = c.get("principal"); |
| 541 | let canWrite = false; |
| 542 | if (p.type === "repo-token") canWrite = p.repoId === repo.id && allows(t.access.role, "push"); |
| 543 | else if (p.type === "account") { |
| 544 | const r = await c.get("dir").roleFor(p.account, repo); |
| 545 | canWrite = !!r && ["write", "maintain", "admin"].includes(r); |
| 546 | } |
| 547 | if (!canWrite) return; |
| 548 | await t.store.updatePackage(pkg.id, { repoId: repo.id }); |
| 549 | pkg.repo_id = repo.id; |
| 550 | pkg.repo_name = repo.name; |
| 551 | } |
| 552 | |
| 553 | async function deleteManifest(c: AppContext, full: string, reference: string): Promise<Response> { |
| 554 | const t = await target(c, full, "admin"); |
| 555 | const pkg = requirePackage(t); |
| 556 | if (DIGEST_RE.test(reference)) { |
| 557 | if (!(await t.store.deleteManifest(pkg.id, reference))) throw new RegistryError(404, "MANIFEST_UNKNOWN", `manifest ${reference} not found`); |
| 558 | } else { |
| 559 | if (!TAG_RE.test(reference)) throw new RegistryError(400, "MANIFEST_INVALID", `invalid tag "${reference}"`); |
| 560 | if (!(await t.store.deleteTag(pkg.id, reference))) throw new RegistryError(404, "MANIFEST_UNKNOWN", `tag ${reference} not found`); |
| 561 | } |
| 562 | c.executionCtx.waitUntil( |
| 563 | c |
| 564 | .get("dir") |
| 565 | .audit({ actorId: t.access.actor?.id, actorLogin: t.access.actor?.login, action: "package.delete_version", target: `${t.owner.login}/${t.name}@${reference}` }) |
| 566 | .catch((e) => console.error("audit failed", e)), |
| 567 | ); |
| 568 | return new Response(null, { status: 202 }); |
| 569 | } |
| 570 | |
| 571 | async function referrers(c: AppContext, full: string, digest: string): Promise<Response> { |
| 572 | if (!DIGEST_RE.test(digest)) throw new RegistryError(400, "DIGEST_INVALID", "only sha256 digests are supported"); |
| 573 | const t = await target(c, full, "pull"); |
| 574 | const pkg = requirePackage(t); |
| 575 | const type = c.req.query("artifactType"); |
| 576 | let rows = await t.store.referrers(pkg.id, digest); |
| 577 | if (type) rows = rows.filter((r) => r.artifact_type === type); |
| 578 | const body = { |
| 579 | schemaVersion: 2, |
| 580 | mediaType: "application/vnd.oci.image.index.v1+json", |
| 581 | manifests: rows.map((r) => ({ mediaType: r.media_type, digest: r.digest, size: r.size, ...(r.artifact_type ? { artifactType: r.artifact_type } : {}), ...(r.annotations ? { annotations: JSON.parse(r.annotations) } : {}) })), |
| 582 | }; |
| 583 | const headers: Record<string, string> = { "content-type": "application/vnd.oci.image.index.v1+json" }; |
| 584 | if (type) headers["oci-filters-applied"] = "artifactType"; |
| 585 | return new Response(JSON.stringify(body), { status: 200, headers }); |
| 586 | } |
| 587 | |
| 588 | // ---------------------------------------------------------------- listing |
| 589 | |
| 590 | async function tagsList(c: AppContext, full: string): Promise<Response> { |
| 591 | const t = await target(c, full, "pull"); |
| 592 | const pkg = requirePackage(t); |
| 593 | const nq = c.req.query("n"); |
| 594 | const n = nq === undefined ? undefined : Math.max(0, Math.min(Number(nq) || 0, 10000)); |
| 595 | const last = c.req.query("last"); |
| 596 | const tags = await t.store.tags(pkg.id, { n: n === undefined ? undefined : n + 1, last }); |
| 597 | const page = n === undefined ? tags : tags.slice(0, n); |
| 598 | const headers: Record<string, string> = {}; |
| 599 | if (n !== undefined && tags.length > n && page.length) headers.link = `</v2/${t.full}/tags/list?n=${n}&last=${encodeURIComponent(page[page.length - 1]!)}>; rel="next"`; |
| 600 | return c.json({ name: `${t.owner.login}/${t.name}`, tags: page }, 200, headers); |
| 601 | } |
| 602 | |
| 603 | async function catalog(c: AppContext): Promise<Response> { |
| 604 | const p = c.get("principal"); |
| 605 | if (p.type === "anonymous") throw new RegistryError(401, "UNAUTHORIZED", "authentication required"); |
| 606 | const dir = c.get("dir"); |
| 607 | const store = new RegistryStore(c.env); |
| 608 | let rows: PackageRow[]; |
| 609 | if (p.type === "account" && p.account.site_admin && !p.repoIds) rows = await store.packages({ all: true, limit: 5000 }); |
| 610 | else if (p.type === "account") { |
| 611 | const subject = p.account.kind === "agent" ? p.account.owner_id! : p.account.id; |
| 612 | const orgs = (await dir.orgsFor(subject)).map((o) => o.id); |
| 613 | const repos = (await dir.reposVisibleTo(p.account)).map((r) => r.id); |
| 614 | rows = await store.packages({ ownerIds: p.repoIds ? [] : [subject, ...orgs], repoIds: p.repoIds ?? repos, limit: 5000 }); |
| 615 | // An organization's packages linked to a repository are listed only to those who can see it. |
| 616 | const seen = new Set(repos); |
| 617 | rows = rows.filter((r) => r.owner_id === subject || !r.repo_id || seen.has(r.repo_id)); |
| 618 | } else { |
| 619 | const repo = await dir.repoById(p.repoId); |
| 620 | rows = repo ? await store.packages({ ownerIds: [repo.owner_id], limit: 5000 }) : []; |
| 621 | } |
| 622 | const names = rows.map((r) => `${r.owner_login.toLowerCase()}/${r.name}`).sort(); |
| 623 | const nq = c.req.query("n"); |
| 624 | const last = c.req.query("last"); |
| 625 | let page = last ? names.filter((x) => x > last) : names; |
| 626 | const headers: Record<string, string> = {}; |
| 627 | if (nq !== undefined) { |
| 628 | const n = Math.max(0, Number(nq) || 0); |
| 629 | if (page.length > n && n > 0) headers.link = `</v2/_catalog?n=${n}&last=${encodeURIComponent(page[n - 1]!)}>; rel="next"`; |
| 630 | page = page.slice(0, n); |
| 631 | } |
| 632 | return c.json({ repositories: page }, 200, headers); |
| 633 | } |