Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

633 lines · 32.6 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import type { AppContext, AppVars } from "../auth/principal.js";
4import type { Account } from "../lib/directory.js";
5import { HttpError } from "../lib/util.js";
6import { checkStorageQuota, storageHeadroom } from "../lib/quotas.js";
7import { allows, packageAccess, type PackageAccess, type PackageRole } from "./access.js";
8import { DIGEST_RE, NAME_RE, RegistryStore, TAG_RE, manifestKey, type ManifestRow, type PackageRow, type UploadRow } from "./store.js";
9import { 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 */
18export const registry = new Hono<{ Bindings: Env; Variables: AppVars }>();
19
20export 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]);
26const INDEX_TYPES = new Set(["application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"]);
27const MAX_MANIFEST = 4 * 1024 * 1024;
28const API_HEADERS = { "docker-distribution-api-version": "registry/2.0" };
29
30export 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
41function 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
45const challenge = (c: AppContext) => ({ "www-authenticate": `Basic realm="${new URL(c.env.PUBLIC_URL).host}", charset="UTF-8"` });
46
47const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
48
49// ---------------------------------------------------------------- routing
50
51registry.all("/v2", (c) => c.redirect("/v2/", 301));
52
53registry.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
67async 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
99interface 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. */
110async 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). */
155async 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
168function 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
175async 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
196function 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
213async 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
222const uploadUrl = (t: Target, id: string) => `/v2/${t.full}/blobs/uploads/${id}`;
223
224function 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. */
230function 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). */
235export 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. */
238async 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. */
245async 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
256async 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
287async 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
295async 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
300function 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
307async 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
316async 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
325async 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. */
333async 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
351function 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
357async 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
364async 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
372async 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
380interface 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
390interface 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
402const platformName = (p?: Descriptor["platform"]) => (p?.os && p.architecture ? `${p.os}/${p.architecture}${p.variant ? `/${p.variant}` : ""}` : null);
403
404async 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
496interface 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. */
504async 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 */
524async 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
553async 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
571async 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
590async 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
603async 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}