| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | import { requireAccount } from "../auth/principal.js"; |
| 5 | import type { Account } from "../lib/directory.js"; |
| 6 | import { HttpError } from "../lib/util.js"; |
| 7 | import { loadRepo } from "../http/common.js"; |
| 8 | import { allows, packageAccess, type PackageRole } from "./access.js"; |
| 9 | import { RegistryStore, type ManifestRow, type PackageRow } from "./store.js"; |
| 10 | |
| 11 | /** |
| 12 | * Packages REST API, shaped like GitHub's (/user/packages, /orgs/{org}/packages, |
| 13 | * versions and deletes), so scripts and actions such as |
| 14 | * actions/delete-package-versions work. Only the container package type exists. |
| 15 | */ |
| 16 | export const packagesApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); |
| 17 | |
| 18 | const host = (c: AppContext) => new URL(c.env.PUBLIC_URL).host; |
| 19 | |
| 20 | export function packageJson(c: AppContext, p: PackageRow & { version_count?: number; tag_count?: number }) { |
| 21 | const base = `${c.env.PUBLIC_URL}/${p.owner_login}/-/packages/container/${encodeURIComponent(p.name)}`; |
| 22 | return { |
| 23 | id: p.id, |
| 24 | name: p.name, |
| 25 | package_type: "container", |
| 26 | owner: { login: p.owner_login, type: p.owner_kind === "org" ? "Organization" : "User" }, |
| 27 | version_count: p.version_count, |
| 28 | visibility: p.visibility, |
| 29 | description: p.description, |
| 30 | image: `${host(c)}/${p.owner_login.toLowerCase()}/${p.name}`, |
| 31 | url: `${c.env.PUBLIC_URL}/api/v3/${p.owner_kind === "org" ? "orgs" : "users"}/${p.owner_login}/packages/container/${encodeURIComponent(p.name)}`, |
| 32 | html_url: base, |
| 33 | repository: p.repo_id ? { id: p.repo_id, name: p.repo_name, full_name: `${p.owner_login}/${p.repo_name}`, html_url: `${c.env.PUBLIC_URL}/${p.owner_login}/${p.repo_name}` } : null, |
| 34 | created_at: p.created_at, |
| 35 | updated_at: p.updated_at, |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | export function versionJson(c: AppContext, p: PackageRow, v: ManifestRow & { tags: string[] }) { |
| 40 | return { |
| 41 | id: v.id, |
| 42 | name: v.digest, |
| 43 | url: `${packageJson(c, p).url}/versions/${v.id}`, |
| 44 | package_html_url: packageJson(c, p).html_url, |
| 45 | html_url: `${packageJson(c, p).html_url}/${v.digest}`, |
| 46 | created_at: v.created_at, |
| 47 | updated_at: v.created_at, |
| 48 | media_type: v.media_type, |
| 49 | size: v.total_size, |
| 50 | platforms: v.platforms ? (JSON.parse(v.platforms) as Array<{ platform: string | null }>).map((x) => x.platform).filter(Boolean) : [], |
| 51 | pushed_by: v.pushed_by, |
| 52 | metadata: { package_type: "container", container: { tags: v.tags } }, |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function checkType(c: AppContext): void { |
| 57 | const t = c.req.param("type"); |
| 58 | if (t && t !== "container" && t !== "docker") throw new HttpError(404, `Diffinery hosts container packages only (got ${t})`); |
| 59 | const q = c.req.query("package_type"); |
| 60 | if (q && q !== "container" && q !== "docker") throw new HttpError(422, "package_type must be container"); |
| 61 | } |
| 62 | |
| 63 | async function ownerAccount(c: AppContext, login: string, kind?: "org" | "user"): Promise<Account> { |
| 64 | const a = await c.get("dir").accountByLogin(login); |
| 65 | if (!a || a.kind === "agent" || (kind && a.kind !== kind)) throw new HttpError(404, "not found"); |
| 66 | return a; |
| 67 | } |
| 68 | |
| 69 | /** Packages under an owner that the caller can pull. */ |
| 70 | async function listFor(c: AppContext, owner: Account) { |
| 71 | checkType(c); |
| 72 | const store = new RegistryStore(c.env); |
| 73 | const rows = await store.packages({ ownerIds: [owner.id], all: false, limit: 1000 }); |
| 74 | const out = []; |
| 75 | for (const p of rows.filter((r) => r.owner_id === owner.id)) { |
| 76 | if (allows((await packageAccess(c, owner, p.name, p)).role, "pull")) out.push(packageJson(c, p)); |
| 77 | } |
| 78 | const vis = c.req.query("visibility"); |
| 79 | return vis ? out.filter((p) => p.visibility === vis) : out; |
| 80 | } |
| 81 | |
| 82 | async function loadPackage(c: AppContext, owner: Account, need: PackageRole) { |
| 83 | checkType(c); |
| 84 | const name = decodeURIComponent(c.req.param("name")!).toLowerCase(); |
| 85 | const store = new RegistryStore(c.env); |
| 86 | const pkg = await store.package(owner.id, name); |
| 87 | if (!pkg) throw new HttpError(c.get("principal").type === "anonymous" ? 401 : 404, "package not found"); |
| 88 | const access = await packageAccess(c, owner, name, pkg); |
| 89 | if (!allows(access.role, "pull")) throw new HttpError(c.get("principal").type === "anonymous" ? 401 : 404, "package not found"); |
| 90 | if (!allows(access.role, need)) throw new HttpError(403, `${need} access to this package is required${access.hint ? `: ${access.hint}` : ""}`); |
| 91 | return { store, pkg, access }; |
| 92 | } |
| 93 | |
| 94 | type OwnerOf = (c: AppContext) => Promise<Account>; |
| 95 | const owners: Array<[string, OwnerOf]> = [ |
| 96 | ["/user/packages", async (c) => requireAccount(c)], |
| 97 | ["/users/:owner/packages", (c) => ownerAccount(c, c.req.param("owner")!, "user")], |
| 98 | ["/orgs/:owner/packages", (c) => ownerAccount(c, c.req.param("owner")!, "org")], |
| 99 | ]; |
| 100 | |
| 101 | /** |
| 102 | * A workflow's or sandbox's token pushes to its repository's packages and deletes their versions, as on GitHub, |
| 103 | * but never makes one public or deletes it outright: that takes a person (or agent) with admin access. |
| 104 | */ |
| 105 | function workflowTokensMayNot(c: AppContext, what: string): void { |
| 106 | if (c.get("principal").type === "repo-token") throw new HttpError(403, `a workflow's token can't ${what}; someone with admin access to the package can`); |
| 107 | } |
| 108 | |
| 109 | for (const [prefix, ownerOf] of owners) { |
| 110 | packagesApi.get(prefix, async (c) => c.json(await listFor(c, await ownerOf(c)))); |
| 111 | |
| 112 | packagesApi.get(`${prefix}/:type/:name`, async (c) => { |
| 113 | const owner = await ownerOf(c); |
| 114 | const { store, pkg } = await loadPackage(c, owner, "pull"); |
| 115 | const versions = await store.versions(pkg.id); |
| 116 | return c.json({ ...packageJson(c, pkg), version_count: versions.length }); |
| 117 | }); |
| 118 | |
| 119 | // Diffinery: change visibility or the linked repository ({"repository": "name"} or null). |
| 120 | packagesApi.patch(`${prefix}/:type/:name`, async (c) => { |
| 121 | const owner = await ownerOf(c); |
| 122 | workflowTokensMayNot(c, "change a package's visibility or repository"); |
| 123 | const { store, pkg, access } = await loadPackage(c, owner, "admin"); |
| 124 | const b = (await c.req.json().catch(() => ({}))) as { visibility?: string; repository?: string | null }; |
| 125 | const patch: Parameters<RegistryStore["updatePackage"]>[1] = {}; |
| 126 | if (b.visibility !== undefined) { |
| 127 | if (!["private", "public"].includes(b.visibility)) throw new HttpError(422, "visibility must be private or public"); |
| 128 | patch.visibility = b.visibility as PackageRow["visibility"]; |
| 129 | } |
| 130 | if (b.repository !== undefined) { |
| 131 | if (b.repository === null || b.repository === "") patch.repoId = null; |
| 132 | else { |
| 133 | const name = b.repository.includes("/") ? b.repository.split("/").pop()! : b.repository; |
| 134 | const { repo } = await loadRepo(c, owner.login, name, "admin", { whileArchived: true }); |
| 135 | patch.repoId = repo.id; |
| 136 | } |
| 137 | } |
| 138 | await store.updatePackage(pkg.id, patch); |
| 139 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.update", target: `${owner.login}/${pkg.name}`, detail: b }); |
| 140 | return c.json(packageJson(c, (await store.packageById(pkg.id))!)); |
| 141 | }); |
| 142 | |
| 143 | packagesApi.delete(`${prefix}/:type/:name`, async (c) => { |
| 144 | const owner = await ownerOf(c); |
| 145 | workflowTokensMayNot(c, "delete a whole package"); |
| 146 | const { store, pkg, access } = await loadPackage(c, owner, "admin"); |
| 147 | await store.deletePackage(pkg.id); |
| 148 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.delete", target: `${owner.login}/${pkg.name}` }); |
| 149 | return c.body(null, 204); |
| 150 | }); |
| 151 | |
| 152 | packagesApi.get(`${prefix}/:type/:name/versions`, async (c) => { |
| 153 | const owner = await ownerOf(c); |
| 154 | const { store, pkg } = await loadPackage(c, owner, "pull"); |
| 155 | const perPage = Math.min(Math.max(Number(c.req.query("per_page") ?? 30) || 30, 1), 100); |
| 156 | const page = Math.max(Number(c.req.query("page") ?? 1) || 1, 1); |
| 157 | const tagged = c.req.query("state") === "tagged"; |
| 158 | let versions = await store.versions(pkg.id); |
| 159 | if (tagged) versions = versions.filter((v) => v.tags.length); |
| 160 | return c.json(versions.slice((page - 1) * perPage, page * perPage).map((v) => versionJson(c, pkg, v))); |
| 161 | }); |
| 162 | |
| 163 | packagesApi.get(`${prefix}/:type/:name/versions/:id`, async (c) => { |
| 164 | const owner = await ownerOf(c); |
| 165 | const { store, pkg } = await loadPackage(c, owner, "pull"); |
| 166 | const v = await store.versionById(pkg.id, Number(c.req.param("id"))); |
| 167 | if (!v) throw new HttpError(404, "version not found"); |
| 168 | return c.json(versionJson(c, pkg, v)); |
| 169 | }); |
| 170 | |
| 171 | packagesApi.delete(`${prefix}/:type/:name/versions/:id`, async (c) => { |
| 172 | const owner = await ownerOf(c); |
| 173 | const { store, pkg, access } = await loadPackage(c, owner, "admin"); |
| 174 | const v = await store.versionById(pkg.id, Number(c.req.param("id"))); |
| 175 | if (!v) throw new HttpError(404, "version not found"); |
| 176 | await store.deleteManifest(pkg.id, v.digest); |
| 177 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.delete_version", target: `${owner.login}/${pkg.name}@${v.digest}` }); |
| 178 | return c.body(null, 204); |
| 179 | }); |
| 180 | } |
| 181 | |
| 182 | // Diffinery: the packages linked to a repository. |
| 183 | packagesApi.get("/repos/:owner/:repo/packages", async (c) => { |
| 184 | const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read"); |
| 185 | const rows = await new RegistryStore(c.env).packages({ repoId: repo.id }); |
| 186 | return c.json(rows.map((p) => packageJson(c, p))); |
| 187 | }); |