g1t/services/repos/src/index.ts

309 lines9,327 bytesCodeBlame
1import { WorkerEntrypoint } from "cloudflare:workers";
2
3import {
4 type BlobView,
5 type Commit,
6 type CreateRepoInput,
7 type EventsApi,
8 type GitAccess,
9 type GitService,
10 type ServiceBinding,
11 type NewEvent,
12 type Repo,
13 type RepoPath,
14 type ReposApi,
15 type Result,
16 type TreeView,
17 type User,
18 type Viewer,
19 fail,
20 identityClient,
21 isValidNamespace,
22 isValidRepoName,
23 newId,
24 ok,
25} from "@g1t/contracts";
26
27import { ArtifactsGitStore } from "./artifacts-git-store";
28import { handleGitHttp } from "./git-http";
29import type { GitStore } from "./git-store";
30import {
31 RepoRegistry,
32 canRead,
33 canWrite,
34 storeKey,
35} from "./registry";
36
37export interface ReposEnv {
38 DB: D1Database;
39 ARTIFACTS: Artifacts;
40 IDENTITY: ServiceBinding;
41 EVENTS: EventsApi;
42}
43
44/** Namespace that holds every attempt's fork: `attempts/<attempt id>`. */
45const ATTEMPTS_NAMESPACE = "attempts";
46const MAX_TEXT_BYTES = 512 * 1024;
47const README = /^readme(\.(md|markdown|txt))?$/i;
48const SOURCE = "repos";
49
50const NOT_FOUND = fail("not_found", "Repository not found.");
51
52/** Decoded text, or null when the blob is too large or looks binary. */
53async function blobText(blob: Blob): Promise<string | null> {
54 if (blob.size > MAX_TEXT_BYTES) return null;
55 const bytes = new Uint8Array(await blob.arrayBuffer());
56 if (bytes.includes(0)) return null;
57 return new TextDecoder().decode(bytes);
58}
59
60export default class ReposService
61 extends WorkerEntrypoint<ReposEnv>
62 implements ReposApi
63{
64 private readonly registry = new RepoRegistry(this.env.DB);
65 private readonly store: GitStore = new ArtifactsGitStore(this.env.ARTIFACTS);
66
67 /** Resolves a repo the viewer may read; private repos look missing. */
68 private async readable(path: RepoPath, viewer: Viewer): Promise<Repo | null> {
69 const repo = await this.registry.byPath(path);
70 return repo && canRead(repo, viewer) ? repo : null;
71 }
72
73 async get(path: RepoPath, viewer: Viewer): Promise<Result<Repo>> {
74 const repo = await this.readable(path, viewer);
75 return repo ? ok(repo) : NOT_FOUND;
76 }
77
78 async getById(id: string, viewer: Viewer): Promise<Result<Repo>> {
79 const repo = await this.registry.byId(id);
80 return repo && canRead(repo, viewer) ? ok(repo) : NOT_FOUND;
81 }
82
83 async list(
84 viewer: Viewer,
85 options: { query?: string; namespace?: string } = {},
86 ): Promise<Repo[]> {
87 return this.registry.list(viewer, options);
88 }
89
90 async create(owner: User, input: CreateRepoInput): Promise<Result<Repo>> {
91 const name = input.name.trim().toLowerCase();
92 if (!isValidRepoName(name)) {
93 return fail("invalid", "Use letters, digits, dots, hyphens and underscores only.");
94 }
95 if (!isValidNamespace(owner.username)) {
96 return fail("invalid", "This account cannot own repositories.");
97 }
98 const path = { namespace: owner.username, name };
99 if (await this.registry.byPath(path)) {
100 return fail("conflict", "You already have a repository with that name.");
101 }
102 const repo: Repo = {
103 id: newId("rep"),
104 ...path,
105 description: input.description?.trim() || null,
106 isPrivate: input.isPrivate ?? false,
107 ownerId: owner.id,
108 defaultBranch: "main",
109 forkOf: null,
110 createdAt: Date.now(),
111 };
112 await this.store.create(storeKey(repo), {
113 description: repo.description ?? undefined,
114 defaultBranch: repo.defaultBranch,
115 });
116 await this.registry.insert(repo);
117 await this.publish({
118 type: "repo.created",
119 source: SOURCE,
120 repoId: repo.id,
121 actor: owner.id,
122 data: {
123 repoId: repo.id,
124 namespace: repo.namespace,
125 name: repo.name,
126 isPrivate: repo.isPrivate,
127 },
128 });
129 return ok(repo);
130 }
131
132 async tree(
133 path: RepoPath,
134 viewer: Viewer,
135 ref: string | null,
136 treePath: string,
137 ): Promise<Result<TreeView>> {
138 const repo = await this.readable(path, viewer);
139 if (!repo) return NOT_FOUND;
140 const key = storeKey(repo);
141 const resolvedRef = ref ?? repo.defaultBranch;
142 const base = { repo, ref: resolvedRef, path: treePath };
143
144 const [head] = await this.store.log(key, resolvedRef, 1);
145 if (!head) {
146 // An unknown ref is an error; a repo with no commits is just empty.
147 if (ref) return fail("not_found", "No such branch, tag or commit.");
148 return ok({ ...base, head: null, entries: [], readme: null });
149 }
150
151 let entries = await this.store.readTree(key, head.treeHash);
152 for (const segment of treePath.split("/").filter(Boolean)) {
153 const next = entries?.find(
154 (entry) => entry.name === segment && entry.kind === "tree",
155 );
156 if (!next) return fail("not_found", "No such directory.");
157 entries = await this.store.readTree(key, next.hash);
158 }
159 if (!entries) return fail("not_found", "No such directory.");
160 entries.sort(
161 (a, b) =>
162 Number(b.kind === "tree") - Number(a.kind === "tree") ||
163 a.name.localeCompare(b.name),
164 );
165
166 const readmeEntry = entries.find(
167 (entry) => entry.kind === "blob" && README.test(entry.name),
168 );
169 const readmeBlob = readmeEntry
170 ? await this.store.readBlob(key, readmeEntry.hash)
171 : null;
172 const readme =
173 readmeEntry && readmeBlob
174 ? { name: readmeEntry.name, text: await blobText(readmeBlob) }
175 : null;
176 return ok({ ...base, head, entries, readme });
177 }
178
179 async blob(
180 path: RepoPath,
181 viewer: Viewer,
182 ref: string,
183 filePath: string,
184 ): Promise<Result<BlobView>> {
185 const repo = await this.readable(path, viewer);
186 if (!repo) return NOT_FOUND;
187 const blob = filePath
188 ? await this.store.readFile(storeKey(repo), ref, filePath)
189 : null;
190 if (!blob) return fail("not_found", "No such file.");
191 return ok({
192 repo,
193 ref,
194 path: filePath,
195 size: blob.size,
196 text: await blobText(blob),
197 });
198 }
199
200 async log(
201 path: RepoPath,
202 viewer: Viewer,
203 ref: string | null,
204 limit: number,
205 ): Promise<Result<Commit[]>> {
206 const repo = await this.readable(path, viewer);
207 if (!repo) return NOT_FOUND;
208 return ok(
209 await this.store.log(storeKey(repo), ref ?? repo.defaultBranch, limit),
210 );
211 }
212
213 async forkForAttempt(
214 sourceId: string,
215 attemptId: string,
216 actor: User,
217 ): Promise<Result<Repo>> {
218 const source = await this.registry.byId(sourceId);
219 if (!source || !canRead(source, actor)) return NOT_FOUND;
220 const fork: Repo = {
221 id: newId("rep"),
222 namespace: ATTEMPTS_NAMESPACE,
223 name: attemptId,
224 description: null,
225 // A fork is exactly as visible as the repo it came from.
226 isPrivate: source.isPrivate,
227 ownerId: actor.id,
228 defaultBranch: source.defaultBranch,
229 forkOf: source.id,
230 createdAt: Date.now(),
231 };
232 await this.store.fork(storeKey(source), storeKey(fork));
233 await this.registry.insert(fork);
234 await this.publish({
235 type: "repo.forked",
236 source: SOURCE,
237 repoId: source.id,
238 actor: actor.id,
239 data: { repoId: fork.id, sourceRepoId: source.id, attemptId },
240 });
241 return ok(fork);
242 }
243
244 async gitAccess(
245 path: RepoPath,
246 viewer: Viewer,
247 service: GitService,
248 ): Promise<Result<GitAccess>> {
249 const write = service === "git-receive-pack";
250 let repo = await this.registry.byPath(path);
251 if (!repo) {
252 // Push to create, in the pusher's own namespace only.
253 if (!write || !viewer || viewer.username !== path.namespace.toLowerCase()) {
254 return this.denied(viewer);
255 }
256 const created = await this.create(viewer, { name: path.name });
257 if (!created.ok) return created;
258 repo = created.value;
259 } else if (write ? !canWrite(repo, viewer) : !canRead(repo, viewer)) {
260 return this.denied(viewer);
261 }
262 return ok(await this.store.access(storeKey(repo), write ? "write" : "read"));
263 }
264
265 /**
266 * Anonymous callers are asked to authenticate whether or not the repo
267 * exists, so private repos cannot be told apart from missing ones.
268 */
269 private denied(viewer: Viewer): Result<never> {
270 return viewer
271 ? NOT_FOUND
272 : fail("unauthenticated", "Authentication required.");
273 }
274
275 private async publish(event: NewEvent): Promise<void> {
276 await this.env.EVENTS.publish([event]);
277 }
278
279 /** Git over HTTPS. */
280 async fetch(request: Request): Promise<Response> {
281 const response = await handleGitHttp(request, identityClient(this.env.IDENTITY), this, (path) =>
282 this.ctx.waitUntil(this.pushed(path)),
283 );
284 return response ?? new Response("Not found\n", { status: 404 });
285 }
286
287 /**
288 * Publishes `git.push` after a push has gone through the git front end.
289 * Artifacts' own push subscriptions are per repository, which does not fit
290 * a repo per attempt, so the front end reports pushes itself.
291 */
292 private async pushed(path: RepoPath): Promise<void> {
293 const repo = await this.registry.byPath(path);
294 if (!repo) return;
295 const [head] = await this.store.log(storeKey(repo), repo.defaultBranch, 1);
296 if (!head) return;
297 await this.publish({
298 type: "git.push",
299 source: SOURCE,
300 repoId: repo.id,
301 actor: null,
302 data: {
303 repoId: repo.id,
304 ref: `refs/heads/${repo.defaultBranch}`,
305 after: head.hash,
306 },
307 });
308 }
309}