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