g1t/services/repos/src/index.ts

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