| 1 | import { WorkerEntrypoint } from "cloudflare:workers"; |
| 2 | |
| 3 | import { |
| 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 | |
| 26 | import { ArtifactsGitStore } from "./artifacts-git-store"; |
| 27 | import { handleGitHttp } from "./git-http"; |
| 28 | import type { GitStore } from "./git-store"; |
| 29 | import { |
| 30 | RepoRegistry, |
| 31 | canRead, |
| 32 | canWrite, |
| 33 | storeKey, |
| 34 | } from "./registry"; |
| 35 | |
| 36 | export 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>`. */ |
| 44 | const ATTEMPTS_NAMESPACE = "attempts"; |
| 45 | const MAX_TEXT_BYTES = 512 * 1024; |
| 46 | const README = /^readme(\.(md|markdown|txt))?$/i; |
| 47 | const SOURCE = "repos"; |
| 48 | |
| 49 | const NOT_FOUND = fail("not_found", "Repository not found."); |
| 50 | |
| 51 | /** Decoded text, or null when the blob is too large or looks binary. */ |
| 52 | async 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 | |
| 59 | export 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 | } |