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.

187 lines · 9.2 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import type { AppContext, AppVars } from "../auth/principal.js";
4import { requireAccount } from "../auth/principal.js";
5import type { Account } from "../lib/directory.js";
6import { HttpError } from "../lib/util.js";
7import { loadRepo } from "../http/common.js";
8import { allows, packageAccess, type PackageRole } from "./access.js";
9import { 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 */
16export const packagesApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
17
18const host = (c: AppContext) => new URL(c.env.PUBLIC_URL).host;
19
20export 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
39export 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
56function 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
63async 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. */
70async 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
82async 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
94type OwnerOf = (c: AppContext) => Promise<Account>;
95const 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 */
105function 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
109for (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.
183packagesApi.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});