| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { Child } from "hono/jsx"; |
| 4 | import type { AppContext } from "../auth/principal.js"; |
| 5 | import type { Account } from "../lib/directory.js"; |
| 6 | import { loadRepo } from "../http/common.js"; |
| 7 | import { HttpError } from "../lib/util.js"; |
| 8 | import { allows, packageAccess, type PackageRole } from "../registry/access.js"; |
| 9 | import { RegistryStore, type ManifestRow, type PackageRow } from "../registry/store.js"; |
| 10 | import { ICONS } from "./assets.js"; |
| 11 | import { KSelect } from "./kumo.js"; |
| 12 | import { OrgHeader } from "./account.js"; |
| 13 | import { csrfToken } from "./csrf.js"; |
| 14 | import { Avatar, CopyButton, Form, Time, act, fmtBytes, post, principalAccount, prm, render, repoUrl, type WebApp } from "./ui.js"; |
| 15 | |
| 16 | /** |
| 17 | * Container packages in the web UI: an owner's packages, one package with its tags and |
| 18 | * versions (and settings for admins), and the packages linked to a repository. |
| 19 | */ |
| 20 | |
| 21 | const host = (c: AppContext) => new URL(c.env.PUBLIC_URL).host; |
| 22 | const imageRef = (c: AppContext, p: Pick<PackageRow, "owner_login" | "name">) => `${host(c)}/${p.owner_login.toLowerCase()}/${p.name}`; |
| 23 | const pkgUrl = (p: Pick<PackageRow, "owner_login" | "name">) => `/${p.owner_login}/-/packages/container/${p.name}`; |
| 24 | |
| 25 | async function ownerOf(c: AppContext): Promise<Account> { |
| 26 | const owner = await c.get("dir").accountByLogin(prm(c, "owner")); |
| 27 | if (!owner || owner.kind === "agent") throw new HttpError(404, "not found"); |
| 28 | return owner; |
| 29 | } |
| 30 | |
| 31 | /** An organization's packages pages keep its header and tab bar, with Packages selected. */ |
| 32 | async function orgShell(c: AppContext, org: Account) { |
| 33 | const me = principalAccount(c); |
| 34 | const role = me ? await c.get("dir").orgRole(org.id, me.kind === "agent" ? (me.owner_id ?? me.id) : me.id) : null; |
| 35 | return <OrgHeader org={org} role={role} isOwner={!!me?.site_admin || role === "owner"} tab="packages" />; |
| 36 | } |
| 37 | |
| 38 | async function visible(c: AppContext, owner: Account, rows: PackageRow[]): Promise<PackageRow[]> { |
| 39 | const out: PackageRow[] = []; |
| 40 | for (const p of rows) if (allows((await packageAccess(c, owner, p.name, p)).role, "pull")) out.push(p); |
| 41 | return out; |
| 42 | } |
| 43 | |
| 44 | async function loadPackage(c: AppContext, owner: Account, name: string, need: PackageRole = "pull") { |
| 45 | const store = new RegistryStore(c.env); |
| 46 | const pkg = await store.package(owner.id, name.toLowerCase()); |
| 47 | if (!pkg) throw new HttpError(404, "package not found"); |
| 48 | const access = await packageAccess(c, owner, pkg.name, pkg); |
| 49 | if (!allows(access.role, "pull")) throw new HttpError(c.get("principal").type === "anonymous" ? 401 : 404, "package not found"); |
| 50 | if (!allows(access.role, need)) throw new HttpError(403, "package admins only"); |
| 51 | return { store, pkg, access }; |
| 52 | } |
| 53 | |
| 54 | const VisIcon = ({ v }: { v: string }) => <span class="st faint">{raw(v === "public" ? ICONS.globe : ICONS.lock)}</span>; |
| 55 | |
| 56 | const PackageList = ({ c, rows, showRepo, empty }: { c: AppContext; rows: Array<PackageRow & { version_count?: number; tag_count?: number }>; showRepo: boolean; empty: Child }) => |
| 57 | rows.length ? ( |
| 58 | <ul class="list"> |
| 59 | {rows.map((p) => ( |
| 60 | <li> |
| 61 | <span class="st faint">{raw(ICONS.package)}</span> |
| 62 | <div class="grow"> |
| 63 | <a href={pkgUrl(p)}> |
| 64 | <b> |
| 65 | {p.owner_login}/{p.name} |
| 66 | </b> |
| 67 | </a>{" "} |
| 68 | <span class="badge">{p.visibility}</span> |
| 69 | {p.description ? <div class="muted small">{p.description}</div> : null} |
| 70 | <div class="muted small mono">{imageRef(c, p)}</div> |
| 71 | </div> |
| 72 | {showRepo && p.repo_name ? ( |
| 73 | <a class="small muted" href={`/${p.owner_login}/${p.repo_name}`}> |
| 74 | {raw(ICONS.repo)} {p.repo_name} |
| 75 | </a> |
| 76 | ) : null} |
| 77 | <span class="muted small"> |
| 78 | {p.tag_count ?? 0} tag{p.tag_count === 1 ? "" : "s"} · updated <Time at={p.updated_at} /> |
| 79 | </span> |
| 80 | </li> |
| 81 | ))} |
| 82 | </ul> |
| 83 | ) : ( |
| 84 | <div class="empty">{empty}</div> |
| 85 | ); |
| 86 | |
| 87 | const Usage = ({ c, image, tag }: { c: AppContext; image: string; tag: string }) => { |
| 88 | const login = `docker login ${host(c)} -u <username> -p <token>`; |
| 89 | const pull = `docker pull ${image}:${tag}`; |
| 90 | const workflow = `- name: Sign in to the Diffinery registry |
| 91 | run: echo "\${{ secrets.GITHUB_TOKEN }}" | docker login ${host(c)} -u "\${{ github.actor }}" --password-stdin |
| 92 | - run: | |
| 93 | docker build -t ${image}:\${{ github.sha }} . |
| 94 | docker push ${image}:\${{ github.sha }}`; |
| 95 | return ( |
| 96 | <div class="box"> |
| 97 | <div class="head"> |
| 98 | <h3>Use this image</h3> |
| 99 | </div> |
| 100 | <div class="body"> |
| 101 | <p class="muted small m0">Sign in with any username and a token from Settings → Access tokens as the password (the read scope pulls, write pushes).</p> |
| 102 | <div class="row mt"> |
| 103 | <pre class="cmd one grow m0 mono">{login}</pre> |
| 104 | </div> |
| 105 | <div class="row mt"> |
| 106 | <pre class="cmd one grow m0 mono">{pull}</pre> |
| 107 | <CopyButton text={pull} size="base" /> |
| 108 | </div> |
| 109 | <p class="muted small mt"> |
| 110 | In a workflow, the job's <code>GITHUB_TOKEN</code> signs in. It can push to packages of its own repository (with <code>permissions: packages: write</code> when the workflow sets permissions) and pull its owner's packages. |
| 111 | </p> |
| 112 | <pre class="cmd m0 mono small">{workflow}</pre> |
| 113 | </div> |
| 114 | </div> |
| 115 | ); |
| 116 | }; |
| 117 | |
| 118 | const platformsOf = (v: ManifestRow) => (v.platforms ? (JSON.parse(v.platforms) as Array<{ platform: string | null }>).map((x) => x.platform).filter((x): x is string => !!x && x !== "unknown/unknown") : []); |
| 119 | |
| 120 | export function registerPackages(web: WebApp): void { |
| 121 | web.get("/:owner/-/packages", async (c) => { |
| 122 | const owner = await ownerOf(c); |
| 123 | const rows = await visible(c, owner, (await new RegistryStore(c.env).packages({ ownerIds: [owner.id], limit: 1000 })).filter((p) => p.owner_id === owner.id)); |
| 124 | return render( |
| 125 | c, |
| 126 | { title: `${owner.login} packages` }, |
| 127 | <> |
| 128 | {owner.kind === "org" ? ( |
| 129 | <> |
| 130 | {await orgShell(c, owner)} |
| 131 | <p class="muted small"> |
| 132 | Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span> |
| 133 | </p> |
| 134 | </> |
| 135 | ) : ( |
| 136 | <div class="row mb" style="gap:12px"> |
| 137 | <Avatar login={owner.login} size="lg" version={owner.avatar_version} /> |
| 138 | <div> |
| 139 | <h1 class="m0"> |
| 140 | <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages |
| 141 | </h1> |
| 142 | <div class="muted small"> |
| 143 | Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span> |
| 144 | </div> |
| 145 | </div> |
| 146 | </div> |
| 147 | )} |
| 148 | <div class="box"> |
| 149 | <PackageList |
| 150 | c={c} |
| 151 | rows={rows} |
| 152 | showRepo |
| 153 | empty={ |
| 154 | <> |
| 155 | No packages yet. Push one with <code class="mono">docker push {host(c)}/{owner.login.toLowerCase()}/IMAGE:TAG</code>. |
| 156 | </> |
| 157 | } |
| 158 | /> |
| 159 | </div> |
| 160 | </>, |
| 161 | ); |
| 162 | }); |
| 163 | |
| 164 | web.get("/:owner/-/packages/container/:name{.+}", async (c) => { |
| 165 | const owner = await ownerOf(c); |
| 166 | const { store, pkg, access } = await loadPackage(c, owner, prm(c, "name")); |
| 167 | const versions = await store.versions(pkg.id); |
| 168 | const tagged = versions.flatMap((v) => v.tags.map((t) => ({ tag: t, v }))); |
| 169 | const isAdmin = allows(access.role, "admin"); |
| 170 | const image = imageRef(c, pkg); |
| 171 | const t = csrfToken(c); |
| 172 | const me = principalAccount(c); |
| 173 | const repos = isAdmin && me ? await c.get("dir").reposVisibleTo(me, owner.id) : []; |
| 174 | const latest = tagged.find((x) => x.tag === "latest")?.tag ?? tagged[0]?.tag ?? "latest"; |
| 175 | return render( |
| 176 | c, |
| 177 | { title: `${owner.login}/${pkg.name}`, description: pkg.description ?? `Container image ${image}` }, |
| 178 | <> |
| 179 | {owner.kind === "org" ? await orgShell(c, owner) : null} |
| 180 | <div class="mb"> |
| 181 | <h1 class="m0"> |
| 182 | <VisIcon v={pkg.visibility} /> |
| 183 | <a href={`/${owner.login}/-/packages`}>{owner.login}</a> <span class="faint">/</span> <strong>{pkg.name}</strong> <span class="badge">{pkg.visibility}</span> |
| 184 | </h1> |
| 185 | {pkg.description ? <p class="muted m0">{pkg.description}</p> : null} |
| 186 | <p class="muted small"> |
| 187 | {pkg.repo_name ? ( |
| 188 | <> |
| 189 | Linked to{" "} |
| 190 | <a href={`/${owner.login}/${pkg.repo_name}`}> |
| 191 | {owner.login}/{pkg.repo_name} |
| 192 | </a> |
| 193 | , which sets who can pull and push.{" "} |
| 194 | </> |
| 195 | ) : ( |
| 196 | <>Not linked to a repository. </> |
| 197 | )} |
| 198 | Created <Time at={pkg.created_at} />, updated <Time at={pkg.updated_at} />. |
| 199 | </p> |
| 200 | </div> |
| 201 | <Usage c={c} image={image} tag={latest} /> |
| 202 | <div class="box"> |
| 203 | <div class="head"> |
| 204 | <h3> |
| 205 | {raw(ICONS.tag)} Versions <span class="muted small">({versions.length})</span> |
| 206 | </h3> |
| 207 | </div> |
| 208 | {versions.length ? ( |
| 209 | <table class="tbl"> |
| 210 | <thead> |
| 211 | <tr> |
| 212 | <th>Tags</th> |
| 213 | <th>Digest</th> |
| 214 | <th>Platforms</th> |
| 215 | <th>Size</th> |
| 216 | <th>Pushed</th> |
| 217 | {isAdmin ? <th /> : null} |
| 218 | </tr> |
| 219 | </thead> |
| 220 | <tbody> |
| 221 | {versions.map((v) => ( |
| 222 | <tr> |
| 223 | <td>{v.tags.length ? v.tags.map((x) => <span class="badge green">{x}</span>) : <span class="muted small">untagged</span>}</td> |
| 224 | <td class="mono small"> |
| 225 | <span title={v.digest}>{v.digest.slice(0, 19)}</span> <CopyButton text={`${image}@${v.digest}`} label="Copy" /> |
| 226 | </td> |
| 227 | <td class="small">{platformsOf(v).join(", ") || "—"}</td> |
| 228 | <td class="small">{fmtBytes(v.total_size)}</td> |
| 229 | <td class="small"> |
| 230 | <Time at={v.created_at} /> |
| 231 | {v.pushed_by ? <span class="muted"> by {v.pushed_by}</span> : null} |
| 232 | </td> |
| 233 | {isAdmin ? ( |
| 234 | <td class="right"> |
| 235 | <Form action={`/${owner.login}/-/packages/-/delete-version`} t={t} class="inline" confirm={`Delete ${v.tags.length ? v.tags.join(", ") : v.digest.slice(0, 19)}? Anything pulling it by tag or digest will stop working.`}> |
| 236 | <input type="hidden" name="name" value={pkg.name} /> |
| 237 | <input type="hidden" name="digest" value={v.digest} /> |
| 238 | <button class="btn sm danger">Delete</button> |
| 239 | </Form> |
| 240 | </td> |
| 241 | ) : null} |
| 242 | </tr> |
| 243 | ))} |
| 244 | </tbody> |
| 245 | </table> |
| 246 | ) : ( |
| 247 | <div class="empty">No versions yet.</div> |
| 248 | )} |
| 249 | </div> |
| 250 | {isAdmin ? ( |
| 251 | <> |
| 252 | <Form action={`/${owner.login}/-/packages/-/update`} t={t} class="box"> |
| 253 | <div class="head"> |
| 254 | <h3>{raw(ICONS.gear)} Settings</h3> |
| 255 | </div> |
| 256 | <div class="body"> |
| 257 | <input type="hidden" name="name" value={pkg.name} /> |
| 258 | <div class="field" style="max-width:360px"> |
| 259 | <label>Visibility</label> |
| 260 | <KSelect |
| 261 | name="visibility" |
| 262 | block |
| 263 | label="Visibility" |
| 264 | value={pkg.visibility} |
| 265 | options={[ |
| 266 | { value: "private", label: "Private: people with access to the linked repository or the owner" }, |
| 267 | { value: "public", label: "Public: anyone, without signing in" }, |
| 268 | ]} |
| 269 | /> |
| 270 | </div> |
| 271 | <div class="field" style="max-width:360px"> |
| 272 | <label>Linked repository</label> |
| 273 | <KSelect name="repository" block label="Linked repository" value={pkg.repo_name ?? ""} options={[{ value: "", label: "None" }, ...repos.map((r) => ({ value: r.name, label: `${r.owner_login}/${r.name}` }))]} /> |
| 274 | <div class="hint">The package shares the repository's permissions: readers pull, writers and its workflows push, admins manage.</div> |
| 275 | </div> |
| 276 | <button class="btn primary">Save</button> |
| 277 | </div> |
| 278 | </Form> |
| 279 | <Form action={`/${owner.login}/-/packages/-/delete`} t={t} class="box" confirm={`Delete ${owner.login}/${pkg.name} and all of its versions?`} match={pkg.name}> |
| 280 | <div class="head"> |
| 281 | <h3>Delete this package</h3> |
| 282 | </div> |
| 283 | <div class="body row"> |
| 284 | <input type="hidden" name="name" value={pkg.name} /> |
| 285 | <p class="muted small grow m0">Removes every tag and version. Type the package name to confirm.</p> |
| 286 | <button class="btn danger">Delete package</button> |
| 287 | </div> |
| 288 | </Form> |
| 289 | </> |
| 290 | ) : null} |
| 291 | </>, |
| 292 | ); |
| 293 | }); |
| 294 | |
| 295 | post(web, "/:owner/-/packages/-/update", async (c, f) => { |
| 296 | const owner = await ownerOf(c); |
| 297 | const name = f.s("name"); |
| 298 | return act( |
| 299 | c, |
| 300 | pkgUrl({ owner_login: owner.login, name }), |
| 301 | async () => { |
| 302 | const { store, pkg, access } = await loadPackage(c, owner, name, "admin"); |
| 303 | const visibility = (["private", "public"] as const).find((v) => v === f.s("visibility")); |
| 304 | const repoName = f.s("repository").trim(); |
| 305 | const repoId = repoName ? (await loadRepo(c, owner.login, repoName, "admin", { whileArchived: true })).repo.id : null; |
| 306 | await store.updatePackage(pkg.id, { visibility, repoId }); |
| 307 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.update", target: `${owner.login}/${pkg.name}`, detail: { visibility, repository: repoName || null } }); |
| 308 | }, |
| 309 | "Package settings saved.", |
| 310 | ); |
| 311 | }); |
| 312 | |
| 313 | post(web, "/:owner/-/packages/-/delete-version", async (c, f) => { |
| 314 | const owner = await ownerOf(c); |
| 315 | const name = f.s("name"); |
| 316 | return act( |
| 317 | c, |
| 318 | pkgUrl({ owner_login: owner.login, name }), |
| 319 | async () => { |
| 320 | const { store, pkg, access } = await loadPackage(c, owner, name, "admin"); |
| 321 | const digest = f.s("digest"); |
| 322 | if (!(await store.deleteManifest(pkg.id, digest))) throw new HttpError(404, "version not found"); |
| 323 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.delete_version", target: `${owner.login}/${pkg.name}@${digest}` }); |
| 324 | }, |
| 325 | "Version deleted.", |
| 326 | ); |
| 327 | }); |
| 328 | |
| 329 | post(web, "/:owner/-/packages/-/delete", async (c, f) => { |
| 330 | const owner = await ownerOf(c); |
| 331 | const name = f.s("name"); |
| 332 | return act( |
| 333 | c, |
| 334 | pkgUrl({ owner_login: owner.login, name }), |
| 335 | async () => { |
| 336 | const { store, pkg, access } = await loadPackage(c, owner, name, "admin"); |
| 337 | await store.deletePackage(pkg.id); |
| 338 | await c.get("dir").audit({ actorId: access.actor?.id, actorLogin: access.actor?.login, action: "package.delete", target: `${owner.login}/${pkg.name}` }); |
| 339 | return `/${owner.login}/-/packages`; |
| 340 | }, |
| 341 | "Package deleted.", |
| 342 | ); |
| 343 | }); |
| 344 | |
| 345 | web.get("/:owner/:repo/packages", async (c) => { |
| 346 | const { repo, caller } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 347 | const rows = await new RegistryStore(c.env).packages({ repoId: repo.id }); |
| 348 | const image = `${host(c)}/${repo.owner_login.toLowerCase()}/${repo.name.toLowerCase()}`; |
| 349 | return render( |
| 350 | c, |
| 351 | { title: `Packages · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "packages" } }, |
| 352 | <> |
| 353 | <div class="box"> |
| 354 | <div class="head"> |
| 355 | <h3>{raw(ICONS.package)} Packages</h3> |
| 356 | </div> |
| 357 | <PackageList |
| 358 | c={c} |
| 359 | rows={rows} |
| 360 | showRepo={false} |
| 361 | empty={ |
| 362 | <> |
| 363 | No packages yet. Images pushed as <code class="mono">{image}</code> (or <code class="mono">{image}/…</code>), or by this repository's workflows, show up here. |
| 364 | </> |
| 365 | } |
| 366 | /> |
| 367 | </div> |
| 368 | <Usage c={c} image={image} tag="latest" /> |
| 369 | <p class="muted small"> |
| 370 | <a href={`${repoUrl(repo)}`}>Back to the code</a> · <a href={`/${repo.owner_login}/-/packages`}>All of {repo.owner_login}'s packages</a> |
| 371 | </p> |
| 372 | </>, |
| 373 | ); |
| 374 | }); |
| 375 | } |