CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
oci-registry.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 845fd8a | 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 | ||
| 33 | import { Hono } from "hono"; | |
| 34 | import { createHash, randomUUID } from "crypto"; | |
| 35 | import { eq, and, desc } from "drizzle-orm"; | |
| 36 | import { mkdir } from "node:fs/promises"; | |
| 37 | import { join } from "node:path"; | |
| 38 | import { db } from "../db"; | |
| 39 | import { apiTokens, users, ociRepositories, ociTags } from "../db/schema"; | |
| 40 | import { config } from "../lib/config"; | |
| 41 | import type { AuthEnv } from "../middleware/auth"; | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // OCI error helpers | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | type 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 | ||
| 63 | function 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 | ||
| 80 | function sha256hex(value: string): string { | |
| 81 | return createHash("sha256").update(value).digest("hex"); | |
| 82 | } | |
| 83 | ||
| 84 | type 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 | */ | |
| 93 | async 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 | ||
| 163 | const 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. */ | |
| 173 | function storePath(...segments: string[]): string { | |
| 174 | return join(config.ociStorePath, ...segments); | |
| 175 | } | |
| 176 | ||
| 177 | async function ensureDir(path: string): Promise<void> { | |
| 178 | await mkdir(path, { recursive: true }); | |
| 179 | } | |
| 180 | ||
| 181 | /** Absolute path to a finished blob file. */ | |
| 182 | function 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. */ | |
| 189 | function manifestPath(name: string, ref: string): string { | |
| 190 | return storePath("manifests", name, ref); | |
| 191 | } | |
| 192 | ||
| 193 | /** Absolute path to an in-progress upload chunk file. */ | |
| 194 | function uploadPath(uuid: string): string { | |
| 195 | return storePath("uploads", uuid); | |
| 196 | } | |
| 197 | ||
| 198 | /** Compute sha256 digest of a file on disk. */ | |
| 199 | async 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. */ | |
| 209 | function 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. */ | |
| 218 | async 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. */ | |
| 238 | async 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 | ||
| 259 | const 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 | // --------------------------------------------------------------------------- | |
| 268 | registry.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 | // --------------------------------------------------------------------------- | |
| 279 | registry.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/... */ | |
| 305 | function 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 | |
| 328 | registry.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 | // --------------------------------------------------------------------------- | |
| 512 | registry.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 | // --------------------------------------------------------------------------- | |
| 595 | registry.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 | // --------------------------------------------------------------------------- | |
| 650 | registry.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 | // --------------------------------------------------------------------------- | |
| 795 | registry.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 | ||
| 855 | export default registry; |