Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

375 lines · 16.3 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child } from "hono/jsx";
4import type { AppContext } from "../auth/principal.js";
5import type { Account } from "../lib/directory.js";
6import { loadRepo } from "../http/common.js";
7import { HttpError } from "../lib/util.js";
8import { allows, packageAccess, type PackageRole } from "../registry/access.js";
9import { RegistryStore, type ManifestRow, type PackageRow } from "../registry/store.js";
10import { ICONS } from "./assets.js";
11import { KSelect } from "./kumo.js";
12import { OrgHeader } from "./account.js";
13import { csrfToken } from "./csrf.js";
14import { 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
21const host = (c: AppContext) => new URL(c.env.PUBLIC_URL).host;
22const imageRef = (c: AppContext, p: Pick<PackageRow, "owner_login" | "name">) => `${host(c)}/${p.owner_login.toLowerCase()}/${p.name}`;
23const pkgUrl = (p: Pick<PackageRow, "owner_login" | "name">) => `/${p.owner_login}/-/packages/container/${p.name}`;
24
25async 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. */
32async 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
38async 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
44async 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
54const VisIcon = ({ v }: { v: string }) => <span class="st faint">{raw(v === "public" ? ICONS.globe : ICONS.lock)}</span>;
55
56const 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
87const 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
118const 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
120export 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}