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.

357 lines · 15.4 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 { csrfToken } from "./csrf.js";
13import { 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
20const host = (c: AppContext) => new URL(c.env.PUBLIC_URL).host;
21const imageRef = (c: AppContext, p: Pick<PackageRow, "owner_login" | "name">) => `${host(c)}/${p.owner_login.toLowerCase()}/${p.name}`;
22const pkgUrl = (p: Pick<PackageRow, "owner_login" | "name">) => `/${p.owner_login}/-/packages/container/${p.name}`;
23
24async 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
30async 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
36async 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
46const VisIcon = ({ v }: { v: string }) => <span class="st faint">{raw(v === "public" ? ICONS.globe : ICONS.lock)}</span>;
47
48const 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
79const 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
110const 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
112export 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}