Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit845fd8aunknown_key

Implement OCI container registry — push/pull/delete blobs + manifests

Implement OCI container registry — push/pull/delete blobs + manifests

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: 44f1a02
5 files changed+9490845fd8a1e692400534221b47dd89a1a43406511c
5 changed files+949−0
Addeddrizzle/0083_oci_registry.sql+36−0View fileUnifiedSplit
1-- OCI / Docker container registry tables (Block OCI-1)
2--
3-- oci_repositories — image namespaces, one per (owner, name) pair.
4-- name is the full "owner/image" string as used in `docker push`.
5--
6-- oci_tags — mutable tag → manifest-digest pointers, analogous to git refs.
7-- Each push that specifies a tag upserts the row so the tag always resolves
8-- to the most-recently-pushed manifest digest.
9--
10-- Blob files live on disk at ${OCI_STORE_PATH}/blobs/sha256/<hex> and are
11-- referenced only by digest; no DB row is needed for individual blobs.
12
13CREATE TABLE IF NOT EXISTS oci_repositories (
14 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15 owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16 name TEXT NOT NULL,
17 visibility TEXT NOT NULL DEFAULT 'private',
18 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
20 CONSTRAINT oci_repositories_owner_name UNIQUE (owner_id, name)
21);
22
23CREATE INDEX IF NOT EXISTS idx_oci_repositories_owner ON oci_repositories (owner_id);
24
25CREATE TABLE IF NOT EXISTS oci_tags (
26 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
27 repository_id UUID NOT NULL REFERENCES oci_repositories(id) ON DELETE CASCADE,
28 tag TEXT NOT NULL,
29 manifest_digest TEXT NOT NULL,
30 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32
33 CONSTRAINT oci_tags_repo_tag UNIQUE (repository_id, tag)
34);
35
36CREATE INDEX IF NOT EXISTS idx_oci_tags_repo ON oci_tags (repository_id);
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
133133import orgInsightsRoutes from "./routes/org-insights";
134134import packagesRoutes from "./routes/packages";
135135import packagesApiRoutes from "./routes/packages-api";
136import ociRegistryRoutes from "./routes/oci-registry";
136137import pagesRoutes from "./routes/pages";
137138import projectsRoutes from "./routes/projects";
138139import protectedTagsRoutes from "./routes/protected-tags";
653654app.route("/", orgInsightsRoutes);
654655app.route("/", packagesRoutes);
655656app.route("/", packagesApiRoutes);
657// OCI / Docker container registry — /v2/* (OCI Distribution Spec v1.0)
658app.route("/", ociRegistryRoutes);
656659app.route("/", pagesRoutes);
657660app.route("/", projectsRoutes);
658661app.route("/", protectedTagsRoutes);
Modifiedsrc/db/schema.ts+45−0View fileUnifiedSplit
867867 createdAt: timestamp("created_at").defaultNow().notNull(),
868868});
869869
870// OCI Container Registry — migration 0083_oci_registry.sql
871// oci_repositories tracks image namespaces (e.g. "alice/myapp").
872// oci_tags maps mutable tag names to a manifest digest for a repository.
873
874export const ociRepositories = pgTable(
875 "oci_repositories",
876 {
877 id: uuid("id").primaryKey().defaultRandom(),
878 ownerId: uuid("owner_id")
879 .notNull()
880 .references(() => users.id, { onDelete: "cascade" }),
881 /** Full image name in "<owner>/<image>" format. */
882 name: text("name").notNull(),
883 visibility: text("visibility").notNull().default("private"), // "public" | "private"
884 createdAt: timestamp("created_at").defaultNow().notNull(),
885 },
886 (table) => [
887 uniqueIndex("oci_repositories_owner_name").on(table.ownerId, table.name),
888 index("idx_oci_repositories_owner").on(table.ownerId),
889 ]
890);
891
892export const ociTags = pgTable(
893 "oci_tags",
894 {
895 id: uuid("id").primaryKey().defaultRandom(),
896 repositoryId: uuid("repository_id")
897 .notNull()
898 .references(() => ociRepositories.id, { onDelete: "cascade" }),
899 tag: text("tag").notNull(),
900 manifestDigest: text("manifest_digest").notNull(), // "sha256:<hex64>"
901 createdAt: timestamp("created_at").defaultNow().notNull(),
902 updatedAt: timestamp("updated_at").defaultNow().notNull(),
903 },
904 (table) => [
905 uniqueIndex("oci_tags_repo_tag").on(table.repositoryId, table.tag),
906 index("idx_oci_tags_repo").on(table.repositoryId),
907 ]
908);
909
910export type OciRepository = typeof ociRepositories.$inferSelect;
911export type NewOciRepository = typeof ociRepositories.$inferInsert;
912export type OciTag = typeof ociTags.$inferSelect;
913export type NewOciTag = typeof ociTags.$inferInsert;
914
870915// Block M2 — Web Push subscriptions. One row per (user, endpoint).
871916// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
872917// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
Modifiedsrc/lib/config.ts+10−0View fileUnifiedSplit
8181 ""
8282 );
8383 },
84 /**
85 * Root directory for OCI container registry blob + manifest storage.
86 * Layout:
87 * ${ociStorePath}/blobs/sha256/<hex64> — finished layer/config blobs
88 * ${ociStorePath}/manifests/<name>/<ref> — image manifests by tag or digest
89 * ${ociStorePath}/uploads/<uuid> — in-progress chunked uploads
90 */
91 get ociStorePath() {
92 return process.env.OCI_STORE_PATH || join(process.cwd(), "oci-store");
93 },
8494 /**
8595 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
8696 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
Addedsrc/routes/oci-registry.ts+855−0View fileUnifiedSplit
1/**
2 * OCI Distribution Spec v1.0 — Container Registry
3 *
4 * Implements the standard Docker / OCI image push-pull protocol so teams
5 * can push and pull images directly against Gluecron without GitHub Packages
6 * or an external registry.
7 *
8 * URL surface (all under /v2/):
9 * GET /v2/ — version check (requires auth)
10 * HEAD /v2/:name/blobs/:digest — check blob existence
11 * GET /v2/:name/blobs/:digest — download blob
12 * POST /v2/:name/blobs/uploads/ — start chunked upload
13 * PATCH /v2/:name/blobs/uploads/:uuid — stream blob chunk
14 * PUT /v2/:name/blobs/uploads/:uuid — complete upload
15 * DELETE /v2/:name/blobs/:digest — delete blob
16 * GET /v2/:name/manifests/:ref — get manifest (tag or digest)
17 * PUT /v2/:name/manifests/:ref — push manifest (create tag)
18 * DELETE /v2/:name/manifests/:ref — delete manifest/tag
19 * GET /v2/:name/tags/list — list tags for a repo
20 * GET /v2/_catalog — list all repositories
21 *
22 * Auth: Docker clients send `Authorization: Basic base64(user:token)`.
23 * We validate against the api_tokens table (SHA-256 of the raw token,
24 * same as every other Gluecron API surface). 401 responses carry
25 * `WWW-Authenticate: Basic realm="Gluecron Container Registry"` and
26 * the OCI error JSON body format.
27 *
28 * Storage: blobs on disk at ${OCI_STORE_PATH}/blobs/sha256/<hex>,
29 * manifests at ${OCI_STORE_PATH}/manifests/<name>/<ref>.
30 * In-progress uploads accumulate in ${OCI_STORE_PATH}/uploads/<uuid>.
31 */
32
33import { Hono } from "hono";
34import { createHash, randomUUID } from "crypto";
35import { eq, and, desc } from "drizzle-orm";
36import { mkdir } from "node:fs/promises";
37import { join } from "node:path";
38import { db } from "../db";
39import { apiTokens, users, ociRepositories, ociTags } from "../db/schema";
40import { config } from "../lib/config";
41import type { AuthEnv } from "../middleware/auth";
42
43// ---------------------------------------------------------------------------
44// OCI error helpers
45// ---------------------------------------------------------------------------
46
47type OciErrorCode =
48 | "UNAUTHORIZED"
49 | "DENIED"
50 | "UNSUPPORTED"
51 | "BLOB_UNKNOWN"
52 | "BLOB_UPLOAD_INVALID"
53 | "BLOB_UPLOAD_UNKNOWN"
54 | "DIGEST_INVALID"
55 | "MANIFEST_BLOB_UNKNOWN"
56 | "MANIFEST_INVALID"
57 | "MANIFEST_UNKNOWN"
58 | "NAME_INVALID"
59 | "NAME_UNKNOWN"
60 | "SIZE_INVALID"
61 | "TAG_INVALID";
62
63function ociError(
64 code: OciErrorCode,
65 message: string,
66 detail?: unknown
67): { errors: Array<{ code: string; message: string; detail?: unknown }> } {
68 const err: { code: string; message: string; detail?: unknown } = {
69 code,
70 message,
71 };
72 if (detail !== undefined) err.detail = detail;
73 return { errors: [err] };
74}
75
76// ---------------------------------------------------------------------------
77// Auth
78// ---------------------------------------------------------------------------
79
80function sha256hex(value: string): string {
81 return createHash("sha256").update(value).digest("hex");
82}
83
84type AuthResult =
85 | { ok: true; user: { id: string; username: string } }
86 | { ok: false };
87
88/**
89 * Docker clients send `Authorization: Basic base64("user:token")`.
90 * We ignore the username part and validate the token (password) against
91 * the api_tokens table just like Bearer token auth elsewhere.
92 */
93async function authenticateBasic(authHeader: string | undefined): Promise<AuthResult> {
94 if (!authHeader) return { ok: false };
95
96 let encoded: string;
97 if (authHeader.startsWith("Basic ")) {
98 encoded = authHeader.slice(6).trim();
99 } else if (authHeader.startsWith("Bearer ")) {
100 // Docker Desktop sometimes sends Bearer after the initial 401 WWW-Auth exchange
101 const raw = authHeader.slice(7).trim();
102 if (!raw) return { ok: false };
103 const tokenHash = sha256hex(raw);
104 try {
105 const [tokenRow] = await db
106 .select({ userId: apiTokens.userId, expiresAt: apiTokens.expiresAt, id: apiTokens.id })
107 .from(apiTokens)
108 .where(eq(apiTokens.tokenHash, tokenHash))
109 .limit(1);
110 if (!tokenRow) return { ok: false };
111 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) return { ok: false };
112 const [user] = await db
113 .select({ id: users.id, username: users.username })
114 .from(users)
115 .where(eq(users.id, tokenRow.userId))
116 .limit(1);
117 if (!user) return { ok: false };
118 db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, tokenRow.id)).catch(() => {});
119 return { ok: true, user };
120 } catch {
121 return { ok: false };
122 }
123 } else {
124 return { ok: false };
125 }
126
127 let decoded: string;
128 try {
129 decoded = Buffer.from(encoded, "base64").toString("utf-8");
130 } catch {
131 return { ok: false };
132 }
133
134 // "user:token" — Docker spec allows colons in password, split on first colon only
135 const colonIdx = decoded.indexOf(":");
136 if (colonIdx < 0) return { ok: false };
137 const rawToken = decoded.slice(colonIdx + 1);
138 if (!rawToken) return { ok: false };
139
140 const tokenHash = sha256hex(rawToken);
141 try {
142 const [tokenRow] = await db
143 .select({ userId: apiTokens.userId, expiresAt: apiTokens.expiresAt, id: apiTokens.id })
144 .from(apiTokens)
145 .where(eq(apiTokens.tokenHash, tokenHash))
146 .limit(1);
147 if (!tokenRow) return { ok: false };
148 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) return { ok: false };
149 const [user] = await db
150 .select({ id: users.id, username: users.username })
151 .from(users)
152 .where(eq(users.id, tokenRow.userId))
153 .limit(1);
154 if (!user) return { ok: false };
155 // Best-effort touch
156 db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, tokenRow.id)).catch(() => {});
157 return { ok: true, user };
158 } catch {
159 return { ok: false };
160 }
161}
162
163const UNAUTHORIZED_HEADERS = {
164 "WWW-Authenticate": 'Basic realm="Gluecron Container Registry"',
165 "Content-Type": "application/json",
166};
167
168// ---------------------------------------------------------------------------
169// Storage helpers
170// ---------------------------------------------------------------------------
171
172/** Resolve (and lazily create) storage directories. */
173function storePath(...segments: string[]): string {
174 return join(config.ociStorePath, ...segments);
175}
176
177async function ensureDir(path: string): Promise<void> {
178 await mkdir(path, { recursive: true });
179}
180
181/** Absolute path to a finished blob file. */
182function blobPath(digest: string): string {
183 // digest = "sha256:<hex>" or just "<hex>"
184 const hex = digest.startsWith("sha256:") ? digest.slice(7) : digest;
185 return storePath("blobs", "sha256", hex);
186}
187
188/** Absolute path to a manifest file for a given image name + ref. */
189function manifestPath(name: string, ref: string): string {
190 return storePath("manifests", name, ref);
191}
192
193/** Absolute path to an in-progress upload chunk file. */
194function uploadPath(uuid: string): string {
195 return storePath("uploads", uuid);
196}
197
198/** Compute sha256 digest of a file on disk. */
199async function digestOfFile(path: string): Promise<string> {
200 const file = Bun.file(path);
201 const buf = await file.arrayBuffer();
202 const bytes = new Uint8Array(buf);
203 const h = new Bun.CryptoHasher("sha256");
204 h.update(bytes);
205 return `sha256:${h.digest("hex")}`;
206}
207
208/** Validate that a "sha256:<hex64>" digest string is well-formed. */
209function isValidDigest(digest: string): boolean {
210 return /^sha256:[0-9a-f]{64}$/.test(digest);
211}
212
213// ---------------------------------------------------------------------------
214// DB helpers — oci_repositories + oci_tags
215// ---------------------------------------------------------------------------
216
217/** Find or create an oci_repositories row for owner/image. */
218async function findOrCreateOciRepo(
219 ownerId: string,
220 name: string,
221 visibility: "public" | "private" = "private"
222): Promise<string> {
223 const [existing] = await db
224 .select({ id: ociRepositories.id })
225 .from(ociRepositories)
226 .where(and(eq(ociRepositories.ownerId, ownerId), eq(ociRepositories.name, name)))
227 .limit(1);
228 if (existing) return existing.id;
229
230 const [inserted] = await db
231 .insert(ociRepositories)
232 .values({ ownerId, name, visibility })
233 .returning({ id: ociRepositories.id });
234 return inserted.id;
235}
236
237/** Upsert a tag → digest mapping. */
238async function upsertTag(repositoryId: string, tag: string, manifestDigest: string): Promise<void> {
239 const [existing] = await db
240 .select({ id: ociTags.id })
241 .from(ociTags)
242 .where(and(eq(ociTags.repositoryId, repositoryId), eq(ociTags.tag, tag)))
243 .limit(1);
244
245 if (existing) {
246 await db
247 .update(ociTags)
248 .set({ manifestDigest, updatedAt: new Date() })
249 .where(eq(ociTags.id, existing.id));
250 } else {
251 await db.insert(ociTags).values({ repositoryId, tag, manifestDigest });
252 }
253}
254
255// ---------------------------------------------------------------------------
256// Route setup
257// ---------------------------------------------------------------------------
258
259const registry = new Hono<AuthEnv>();
260
261// The OCI spec uses `:name` which can contain slashes (e.g. "owner/image").
262// Hono's wildcard param ({name:*}) doesn't play well with path segments, so
263// we parse the image name from c.req.path directly in each handler.
264
265// ---------------------------------------------------------------------------
266// GET /v2/ — Version check
267// ---------------------------------------------------------------------------
268registry.get("/v2/", async (c) => {
269 const auth = await authenticateBasic(c.req.header("authorization"));
270 if (!auth.ok) {
271 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
272 }
273 return c.json({}, 200, { "Docker-Distribution-API-Version": "registry/2.0" });
274});
275
276// ---------------------------------------------------------------------------
277// GET /v2/_catalog — list all repositories the caller can see
278// ---------------------------------------------------------------------------
279registry.get("/v2/_catalog", async (c) => {
280 const auth = await authenticateBasic(c.req.header("authorization"));
281 if (!auth.ok) {
282 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
283 }
284
285 try {
286 const rows = await db
287 .select({ name: ociRepositories.name })
288 .from(ociRepositories)
289 .where(eq(ociRepositories.ownerId, auth.user.id))
290 .orderBy(ociRepositories.name);
291
292 return c.json({ repositories: rows.map((r) => r.name) });
293 } catch (err) {
294 console.error("[oci] catalog:", err);
295 return c.json(ociError("UNSUPPORTED", "service error"), 500);
296 }
297});
298
299// ---------------------------------------------------------------------------
300// Blob endpoints — /v2/:name/blobs/...
301// We parse :name as a wildcard from the URL manually.
302// ---------------------------------------------------------------------------
303
304/** Extract image name + remainder from path like /v2/<name>/blobs/... */
305function parseV2Path(
306 path: string,
307 segment: string
308): { name: string; rest: string } | null {
309 // /v2/<name>/blobs/... or /v2/<name>/manifests/... etc.
310 const prefix = "/v2/";
311 if (!path.startsWith(prefix)) return null;
312 const after = path.slice(prefix.length);
313 const segIdx = after.indexOf(`/${segment}/`);
314 if (segIdx < 0) {
315 // check for exact match at end (e.g., /v2/<name>/tags/list)
316 const segEnd = after.indexOf(`/${segment}`);
317 if (segEnd < 0) return null;
318 const name = after.slice(0, segEnd);
319 const rest = after.slice(segEnd + segment.length + 1);
320 return { name, rest };
321 }
322 const name = after.slice(0, segIdx);
323 const rest = after.slice(segIdx + segment.length + 2);
324 return { name, rest };
325}
326
327// HEAD /v2/:name/blobs/:digest
328registry.on(["HEAD", "GET"], "/v2/*", async (c) => {
329 const path = c.req.path;
330
331 // ── HEAD/GET /v2/:name/blobs/:digest ──────────────────────────────────────
332 if (/\/blobs\/sha256:[0-9a-f]{64}$/.test(path)) {
333 const auth = await authenticateBasic(c.req.header("authorization"));
334 if (!auth.ok) {
335 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
336 }
337
338 const digestMatch = path.match(/\/blobs\/(sha256:[0-9a-f]{64})$/);
339 if (!digestMatch) {
340 return c.json(ociError("DIGEST_INVALID", "invalid digest"), 400);
341 }
342 const digest = digestMatch[1];
343 const bp = blobPath(digest);
344 const file = Bun.file(bp);
345 const exists = await file.exists();
346 if (!exists) {
347 return c.json(ociError("BLOB_UNKNOWN", "blob unknown to registry"), 404, {
348 "Docker-Content-Digest": digest,
349 });
350 }
351
352 const headers: Record<string, string> = {
353 "Docker-Content-Digest": digest,
354 "Content-Length": String(file.size),
355 "Content-Type": "application/octet-stream",
356 };
357
358 if (c.req.method === "HEAD") {
359 return new Response(null, { status: 200, headers });
360 }
361
362 // GET — stream the blob
363 return new Response(file.stream(), { status: 200, headers });
364 }
365
366 // ── GET /v2/:name/tags/list ───────────────────────────────────────────────
367 if (path.endsWith("/tags/list")) {
368 const auth = await authenticateBasic(c.req.header("authorization"));
369 if (!auth.ok) {
370 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
371 }
372
373 const parsed = parseV2Path(path, "tags");
374 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
375 const name = parsed.name;
376
377 try {
378 const [repo] = await db
379 .select({ id: ociRepositories.id })
380 .from(ociRepositories)
381 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
382 .limit(1);
383
384 if (!repo) {
385 return c.json({ name, tags: [] });
386 }
387
388 const tagRows = await db
389 .select({ tag: ociTags.tag })
390 .from(ociTags)
391 .where(eq(ociTags.repositoryId, repo.id))
392 .orderBy(ociTags.tag);
393
394 return c.json({ name, tags: tagRows.map((t) => t.tag) });
395 } catch (err) {
396 console.error("[oci] tags/list:", err);
397 return c.json(ociError("UNSUPPORTED", "service error"), 500);
398 }
399 }
400
401 // ── GET /v2/:name/manifests/:ref ──────────────────────────────────────────
402 if (path.includes("/manifests/")) {
403 const auth = await authenticateBasic(c.req.header("authorization"));
404 if (!auth.ok) {
405 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
406 }
407
408 const parsed = parseV2Path(path, "manifests");
409 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
410 const name = parsed.name;
411 const ref = parsed.rest;
412
413 // ref may be a tag name or a digest (sha256:...)
414 try {
415 let resolvedPath: string | null = null;
416 let resolvedDigest: string | null = null;
417
418 if (isValidDigest(ref)) {
419 // Direct digest reference — look up the blob path
420 const bp = blobPath(ref);
421 const file = Bun.file(bp);
422 if (!(await file.exists())) {
423 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
424 }
425 resolvedPath = manifestPath(name, ref);
426 // Fall back: the manifest might be stored by digest directly
427 const mf = Bun.file(resolvedPath);
428 if (!(await mf.exists())) {
429 resolvedPath = bp; // manifests stored as blobs too
430 }
431 resolvedDigest = ref;
432 } else {
433 // Tag name — look up in DB
434 const [repo] = await db
435 .select({ id: ociRepositories.id })
436 .from(ociRepositories)
437 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
438 .limit(1);
439
440 if (!repo) {
441 return c.json(ociError("NAME_UNKNOWN", "repository name not known to registry"), 404);
442 }
443
444 const [tagRow] = await db
445 .select({ manifestDigest: ociTags.manifestDigest })
446 .from(ociTags)
447 .where(and(eq(ociTags.repositoryId, repo.id), eq(ociTags.tag, ref)))
448 .limit(1);
449
450 if (!tagRow) {
451 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
452 }
453
454 resolvedDigest = tagRow.manifestDigest;
455 resolvedPath = manifestPath(name, ref);
456 const mf = Bun.file(resolvedPath);
457 if (!(await mf.exists())) {
458 // Fall back to digest path
459 resolvedPath = blobPath(resolvedDigest);
460 }
461 }
462
463 if (!resolvedPath || !resolvedDigest) {
464 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
465 }
466
467 const file = Bun.file(resolvedPath);
468 if (!(await file.exists())) {
469 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
470 }
471
472 const content = await file.text();
473 let contentType = "application/vnd.oci.image.manifest.v1+json";
474 try {
475 const parsed2 = JSON.parse(content);
476 if (parsed2.mediaType) contentType = parsed2.mediaType;
477 else if (parsed2.schemaVersion === 2 && parsed2.config) {
478 contentType = "application/vnd.docker.distribution.manifest.v2+json";
479 }
480 } catch { /* leave default */ }
481
482 if (c.req.method === "HEAD") {
483 return new Response(null, {
484 status: 200,
485 headers: {
486 "Docker-Content-Digest": resolvedDigest,
487 "Content-Length": String(Buffer.byteLength(content)),
488 "Content-Type": contentType,
489 },
490 });
491 }
492
493 return new Response(content, {
494 status: 200,
495 headers: {
496 "Docker-Content-Digest": resolvedDigest,
497 "Content-Type": contentType,
498 },
499 });
500 } catch (err) {
501 console.error("[oci] get manifest:", err);
502 return c.json(ociError("UNSUPPORTED", "service error"), 500);
503 }
504 }
505
506 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
507});
508
509// ---------------------------------------------------------------------------
510// POST /v2/:name/blobs/uploads/ — start a new upload session
511// ---------------------------------------------------------------------------
512registry.post("/v2/*", async (c) => {
513 const path = c.req.path;
514
515 if (!path.includes("/blobs/uploads")) {
516 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
517 }
518
519 const auth = await authenticateBasic(c.req.header("authorization"));
520 if (!auth.ok) {
521 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
522 }
523
524 // Parse image name
525 const parsed = parseV2Path(path, "blobs");
526 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid image name"), 400);
527 const name = parsed.name;
528
529 // Check for single-request monolithic upload: POST with ?digest=sha256:...
530 const digestParam = c.req.query("digest");
531 if (digestParam) {
532 if (!isValidDigest(digestParam)) {
533 return c.json(ociError("DIGEST_INVALID", "invalid digest format"), 400);
534 }
535 // Single-step upload (used for small layers)
536 try {
537 const body = await c.req.arrayBuffer();
538 const bytes = new Uint8Array(body);
539 const h = new Bun.CryptoHasher("sha256");
540 h.update(bytes);
541 const computed = `sha256:${h.digest("hex")}`;
542 if (computed !== digestParam) {
543 return c.json(ociError("DIGEST_INVALID", "digest mismatch"), 400);
544 }
545 await ensureDir(storePath("blobs", "sha256"));
546 const bp = blobPath(digestParam);
547 const existing = Bun.file(bp);
548 if (!(await existing.exists())) {
549 await Bun.write(bp, bytes);
550 }
551 // Ensure OCI repo record exists
552 await findOrCreateOciRepo(auth.user.id, name);
553 const baseUrl = new URL(c.req.url).origin;
554 return new Response(null, {
555 status: 201,
556 headers: {
557 Location: `${baseUrl}/v2/${name}/blobs/${digestParam}`,
558 "Docker-Content-Digest": digestParam,
559 "Content-Length": "0",
560 },
561 });
562 } catch (err) {
563 console.error("[oci] monolithic upload:", err);
564 return c.json(ociError("UNSUPPORTED", "upload failed"), 500);
565 }
566 }
567
568 // Chunked upload — allocate a UUID
569 const uuid = randomUUID();
570 try {
571 await ensureDir(storePath("uploads"));
572 // Create an empty placeholder so PATCH has something to append to
573 await Bun.write(uploadPath(uuid), new Uint8Array(0));
574 await ensureDir(storePath("blobs", "sha256"));
575 await findOrCreateOciRepo(auth.user.id, name);
576 const baseUrl = new URL(c.req.url).origin;
577 return new Response(null, {
578 status: 202,
579 headers: {
580 Location: `${baseUrl}/v2/${name}/blobs/uploads/${uuid}`,
581 "Docker-Upload-UUID": uuid,
582 Range: "0-0",
583 "Content-Length": "0",
584 },
585 });
586 } catch (err) {
587 console.error("[oci] start upload:", err);
588 return c.json(ociError("UNSUPPORTED", "failed to start upload"), 500);
589 }
590});
591
592// ---------------------------------------------------------------------------
593// PATCH /v2/:name/blobs/uploads/:uuid — stream chunk data
594// ---------------------------------------------------------------------------
595registry.patch("/v2/*", async (c) => {
596 const path = c.req.path;
597 const auth = await authenticateBasic(c.req.header("authorization"));
598 if (!auth.ok) {
599 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
600 }
601
602 // Extract uuid from path: /v2/<name>/blobs/uploads/<uuid>
603 const uuidMatch = path.match(/\/blobs\/uploads\/([^/]+)$/);
604 if (!uuidMatch) {
605 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload not found"), 404);
606 }
607 const uuid = uuidMatch[1];
608
609 const parsed = parseV2Path(path, "blobs");
610 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
611 const name = parsed.name;
612
613 try {
614 const uploadFile = Bun.file(uploadPath(uuid));
615 if (!(await uploadFile.exists())) {
616 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload session not found"), 404);
617 }
618
619 const chunk = await c.req.arrayBuffer();
620 const chunkBytes = new Uint8Array(chunk);
621
622 // Append the chunk to the upload accumulator using Bun's writer
623 const existingBytes = new Uint8Array(await uploadFile.arrayBuffer());
624 const combined = new Uint8Array(existingBytes.length + chunkBytes.length);
625 combined.set(existingBytes, 0);
626 combined.set(chunkBytes, existingBytes.length);
627 await Bun.write(uploadPath(uuid), combined);
628
629 const newSize = combined.length;
630 const baseUrl = new URL(c.req.url).origin;
631 return new Response(null, {
632 status: 202,
633 headers: {
634 Location: `${baseUrl}/v2/${name}/blobs/uploads/${uuid}`,
635 "Docker-Upload-UUID": uuid,
636 Range: `0-${Math.max(0, newSize - 1)}`,
637 "Content-Length": "0",
638 },
639 });
640 } catch (err) {
641 console.error("[oci] patch upload:", err);
642 return c.json(ociError("BLOB_UPLOAD_INVALID", "chunk write failed"), 500);
643 }
644});
645
646// ---------------------------------------------------------------------------
647// PUT /v2/:name/blobs/uploads/:uuid?digest=sha256:... — complete upload
648// PUT /v2/:name/manifests/:ref — push manifest
649// ---------------------------------------------------------------------------
650registry.put("/v2/*", async (c) => {
651 const path = c.req.path;
652 const auth = await authenticateBasic(c.req.header("authorization"));
653 if (!auth.ok) {
654 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
655 }
656
657 // ── PUT manifest ──────────────────────────────────────────────────────────
658 if (path.includes("/manifests/")) {
659 const parsed = parseV2Path(path, "manifests");
660 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
661 const name = parsed.name;
662 const ref = parsed.rest;
663
664 try {
665 const body = await c.req.text();
666 const bytes = Buffer.from(body);
667
668 // Compute the digest of the manifest
669 const h = new Bun.CryptoHasher("sha256");
670 h.update(bytes);
671 const digest = `sha256:${h.digest("hex")}`;
672
673 // Store manifest under both the ref (tag/digest) and the canonical digest path
674 await ensureDir(storePath("manifests", name));
675 await ensureDir(storePath("blobs", "sha256"));
676
677 const mPath = manifestPath(name, ref);
678 await Bun.write(mPath, bytes);
679
680 // Also write under the digest ref so GET by digest works
681 if (ref !== digest) {
682 const mDigestPath = manifestPath(name, digest);
683 await Bun.write(mDigestPath, bytes);
684 }
685
686 // Store the raw bytes in the blob store too (some clients fetch by digest)
687 const bp = blobPath(digest);
688 const existing = Bun.file(bp);
689 if (!(await existing.exists())) {
690 await Bun.write(bp, bytes);
691 }
692
693 // If ref is a tag (not a digest), record in DB
694 const ociRepoId = await findOrCreateOciRepo(auth.user.id, name);
695 if (!isValidDigest(ref)) {
696 await upsertTag(ociRepoId, ref, digest);
697 }
698
699 const contentType =
700 c.req.header("content-type") ||
701 "application/vnd.oci.image.manifest.v1+json";
702
703 return new Response(null, {
704 status: 201,
705 headers: {
706 "Docker-Content-Digest": digest,
707 Location: `/v2/${name}/manifests/${ref}`,
708 "Content-Type": contentType,
709 "Content-Length": "0",
710 },
711 });
712 } catch (err) {
713 console.error("[oci] put manifest:", err);
714 return c.json(ociError("MANIFEST_INVALID", "failed to store manifest"), 500);
715 }
716 }
717
718 // ── PUT /v2/:name/blobs/uploads/:uuid — complete chunked upload ───────────
719 const uuidMatch = path.match(/\/blobs\/uploads\/([^/?]+)/);
720 if (!uuidMatch) {
721 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
722 }
723 const uuid = uuidMatch[1];
724 const digestParam = c.req.query("digest");
725
726 if (!digestParam || !isValidDigest(digestParam)) {
727 return c.json(ociError("DIGEST_INVALID", "missing or invalid digest query param"), 400);
728 }
729
730 try {
731 const uploadFile = Bun.file(uploadPath(uuid));
732 if (!(await uploadFile.exists())) {
733 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload session not found"), 404);
734 }
735
736 // There may be a final chunk in the PUT body
737 const finalChunk = await c.req.arrayBuffer();
738 let allBytes: Uint8Array;
739 const existingBytes = new Uint8Array(await uploadFile.arrayBuffer());
740 if (finalChunk.byteLength > 0) {
741 const chunkBytes = new Uint8Array(finalChunk);
742 allBytes = new Uint8Array(existingBytes.length + chunkBytes.length);
743 allBytes.set(existingBytes, 0);
744 allBytes.set(chunkBytes, existingBytes.length);
745 } else {
746 allBytes = existingBytes;
747 }
748
749 // Verify digest
750 const h = new Bun.CryptoHasher("sha256");
751 h.update(allBytes);
752 const computed = `sha256:${h.digest("hex")}`;
753 if (computed !== digestParam) {
754 return c.json(ociError("DIGEST_INVALID", "digest mismatch"), 400);
755 }
756
757 // Move from uploads to blobs
758 await ensureDir(storePath("blobs", "sha256"));
759 const bp = blobPath(digestParam);
760 const existingBlob = Bun.file(bp);
761 if (!(await existingBlob.exists())) {
762 await Bun.write(bp, allBytes);
763 }
764
765 // Clean up upload temp file
766 const uploadFilePath = uploadPath(uuid);
767 try {
768 await Bun.file(uploadFilePath);
769 // Overwrite with empty so it's not left hanging; true deletion needs fs.unlink
770 const { unlink } = await import("node:fs/promises");
771 await unlink(uploadFilePath).catch(() => {});
772 } catch { /* ignore */ }
773
774 const parsed = parseV2Path(path, "blobs");
775 const name = parsed?.name ?? "unknown";
776
777 return new Response(null, {
778 status: 201,
779 headers: {
780 "Docker-Content-Digest": digestParam,
781 Location: `/v2/${name}/blobs/${digestParam}`,
782 "Content-Length": "0",
783 },
784 });
785 } catch (err) {
786 console.error("[oci] complete upload:", err);
787 return c.json(ociError("BLOB_UPLOAD_INVALID", "failed to finalize upload"), 500);
788 }
789});
790
791// ---------------------------------------------------------------------------
792// DELETE /v2/:name/blobs/:digest
793// DELETE /v2/:name/manifests/:ref
794// ---------------------------------------------------------------------------
795registry.delete("/v2/*", async (c) => {
796 const path = c.req.path;
797 const auth = await authenticateBasic(c.req.header("authorization"));
798 if (!auth.ok) {
799 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
800 }
801
802 const { unlink } = await import("node:fs/promises");
803
804 // ── DELETE /v2/:name/manifests/:ref ───────────────────────────────────────
805 if (path.includes("/manifests/")) {
806 const parsed = parseV2Path(path, "manifests");
807 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
808 const name = parsed.name;
809 const ref = parsed.rest;
810
811 try {
812 const mPath = manifestPath(name, ref);
813 await unlink(mPath).catch(() => {});
814
815 // Remove tag from DB if ref is a tag
816 if (!isValidDigest(ref)) {
817 const [repo] = await db
818 .select({ id: ociRepositories.id })
819 .from(ociRepositories)
820 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
821 .limit(1);
822 if (repo) {
823 await db
824 .delete(ociTags)
825 .where(and(eq(ociTags.repositoryId, repo.id), eq(ociTags.tag, ref)));
826 }
827 }
828
829 return new Response(null, { status: 202 });
830 } catch (err) {
831 console.error("[oci] delete manifest:", err);
832 return c.json(ociError("MANIFEST_UNKNOWN", "manifest not found"), 404);
833 }
834 }
835
836 // ── DELETE /v2/:name/blobs/:digest ────────────────────────────────────────
837 if (path.includes("/blobs/")) {
838 const digestMatch = path.match(/\/blobs\/(sha256:[0-9a-f]{64})$/);
839 if (!digestMatch) {
840 return c.json(ociError("DIGEST_INVALID", "invalid digest"), 400);
841 }
842 const digest = digestMatch[1];
843 const bp = blobPath(digest);
844 const file = Bun.file(bp);
845 if (!(await file.exists())) {
846 return c.json(ociError("BLOB_UNKNOWN", "blob not found"), 404);
847 }
848 await unlink(bp).catch(() => {});
849 return new Response(null, { status: 202 });
850 }
851
852 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
853});
854
855export default registry;
0856