Public: anyone can read this; the crew can change it.
The /api/v1/user routes move out of src/http/api.ts into src/http/api/user.ts, unchanged #7451
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7444-the-api-v1-user-routes-move-out-of-src-h → main 1 commit fork
3 files
changed +749
−746
src/http/api.ts +7 −745
| @@ -13,7 +13,6 @@ import { homeTruth } from "../system/home_truth.ts"; | ||
| 13 | 13 | import { upstreamFacts } from "../actions/upstream_follow.ts"; |
| 14 | 14 | import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| 15 | 15 | import { showProvenance } from "../actions/provenance.ts"; |
| 16 | import { sshCloneUrl } from "../ssh/command.ts"; | |
| 17 | 16 | import { |
| 18 | 17 | APP_ALREADY_YOURS, |
| 19 | 18 | ORIGIN_UNREACHABLE, |
| @@ -65,13 +64,9 @@ import { | ||
| 65 | 64 | unsuspendAccount, |
| 66 | 65 | verifyAccount, |
| 67 | 66 | } from "../actions/accounts.ts"; |
| 68 | import { resendVerification } from "../actions/verify.ts"; | |
| 69 | 67 | import { PROVE_FIRST } from "../actions/verify_words.ts"; |
| 70 | import { CAPPED_WORDS } from "../actions/mail.ts"; | |
| 71 | 68 | import { REPORT_LINK_TITLE } from "../actions/report_words.ts"; |
| 72 | import { VERIFY_WORDS } from "../actions/verify_words.ts"; | |
| 73 | 69 | import { editMessage } from "../actions/edit_words.ts"; |
| 74 | import { emailVerified } from "../core/verify.ts"; | |
| 75 | 70 | import { readHealth } from "../actions/health.ts"; |
| 76 | 71 | import { isProbe } from "../kernel/probe.ts"; |
| 77 | 72 | import { PRIVATE_ADDRESS_REFUSED } from "../kernel/address_words.ts"; |
| @@ -90,23 +85,8 @@ import { | ||
| 90 | 85 | setIssueStateThrough, |
| 91 | 86 | } from "../actions/issues.ts"; |
| 92 | 87 | import { |
| 93 | connectOrigin, | |
| 94 | deleteOriginApp, | |
| 95 | disconnectOrigin, | |
| 96 | listOriginApps, | |
| 97 | listPinned, | |
| 98 | listRemoteRepos, | |
| 99 | listUserOrigins, | |
| 100 | mirrorJobFor, | |
| 101 | pinRepo, | |
| 102 | pollOAuthConnect, | |
| 103 | 88 | reattachMirror, |
| 104 | registerOriginApp, | |
| 105 | startMirrorJob, | |
| 106 | startOAuthConnect, | |
| 107 | syncOriginNow, | |
| 108 | 89 | syncRepoNow, |
| 109 | unpinRepo, | |
| 110 | 90 | ORIGIN_ALREADY_CONNECTED, |
| 111 | 91 | } from "../actions/origins.ts"; |
| 112 | 92 | import { |
| @@ -116,7 +96,6 @@ import { | ||
| 116 | 96 | import { editFiles, type EditFile } from "../actions/contents.ts"; |
| 117 | 97 | import { |
| 118 | 98 | contribute, |
| 119 | forkOrigin, | |
| 120 | 99 | type ContributeResult, |
| 121 | 100 | } from "../actions/contribute.ts"; |
| 122 | 101 | import { |
| @@ -150,16 +129,12 @@ import { | ||
| 150 | 129 | updateRepoSettings, |
| 151 | 130 | } from "../actions/repos.ts"; |
| 152 | 131 | import { |
| 153 | addSshKey, | |
| 154 | changePassword, | |
| 155 | 132 | createToken, |
| 156 | deleteSshKey, | |
| 157 | 133 | deleteToken, |
| 158 | 134 | HUMAN_CHECK_MESSAGE, |
| 159 | 135 | PASSKEY_REQUIRED_MESSAGE, |
| 160 | 136 | signUp, |
| 161 | 137 | SIGNUP_CLOSED_MESSAGE, |
| 162 | updateProfile, | |
| 163 | 138 | } from "../actions/users.ts"; |
| 164 | 139 | import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts"; |
| 165 | 140 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; |
| @@ -184,9 +159,7 @@ import { | ||
| 184 | 159 | type Issue, |
| 185 | 160 | type IssueState, |
| 186 | 161 | } from "../core/issues.ts"; |
| 187 | import { listKeys, publicKey } from "../core/keys.ts"; | |
| 188 | 162 | import { |
| 189 | listOrgsForUser, | |
| 190 | 163 | soleOwnedOrgs, |
| 191 | 164 | lastOwnerSays, |
| 192 | 165 | } from "../core/orgs.ts"; |
| @@ -324,7 +297,6 @@ import { isEnvName } from "../apps/data.ts"; | ||
| 324 | 297 | import { getAppByRepo } from "../core/apps.ts"; |
| 325 | 298 | import { crewRole, isCrew, type Agent } from "../core/agents.ts"; |
| 326 | 299 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 327 | import { listAssignedTo } from "../core/issues.ts"; | |
| 328 | 300 | import { closingFrom } from "../actions/issues.ts"; |
| 329 | 301 | import { |
| 330 | 302 | checkSummary, |
| @@ -352,11 +324,6 @@ import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as | ||
| 352 | 324 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 353 | 325 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| 354 | 326 | import type { Origin } from "../origins/types.ts"; |
| 355 | import { | |
| 356 | sourceFor, | |
| 357 | type RepoSource, | |
| 358 | type SourceError, | |
| 359 | } from "../view/source.ts"; | |
| 360 | 327 | import { eventStream } from "./sse.ts"; |
| 361 | 328 | import { |
| 362 | 329 | applyFleetOp, |
| @@ -414,6 +381,7 @@ import { agentRoutes } from "./api/agents.ts"; | ||
| 414 | 381 | import { boardRoutes } from "./api/boards.ts"; |
| 415 | 382 | import { followingRoutes } from "./api/following.ts"; |
| 416 | 383 | import { labelsRoutes } from "./api/labels.ts"; |
| 384 | import { userRoutes } from "./api/user.ts"; | |
| 417 | 385 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 418 | 386 | import { reportRoutes } from "./api/reports.ts"; |
| 419 | 387 | import { rollbackRoutes } from "./api/rollback.ts"; |
| @@ -599,7 +567,7 @@ function repoJson(ctx: Ctx, repo: Repo) { | ||
| 599 | 567 | * no origin id and the moment it froze — so a client can never read a |
| 600 | 568 | * frozen copy as a quiet upstream. A native repository has none. |
| 601 | 569 | */ |
| 602 | function originFacts(repo: Repo) { | |
| 570 | export function originFacts(repo: Repo) { | |
| 603 | 571 | const host = (url: string): string => URL.parse(url)?.host ?? ""; |
| 604 | 572 | if (repo.origin !== null) |
| 605 | 573 | return { |
| @@ -640,37 +608,8 @@ export function originJson(origin: Origin) { | ||
| 640 | 608 | }; |
| 641 | 609 | } |
| 642 | 610 | |
| 643 | /** | |
| 644 | * Where a live browse is reading from — the banner's facts. The origin's | |
| 645 | * credential and its id are ours; what a client gets is the forge, the name | |
| 646 | * there, the page there, and whether we already hold a mirror of it. | |
| 647 | */ | |
| 648 | function whereJson(source: RepoSource) { | |
| 649 | const where = source.where; | |
| 650 | if (where.kind === "local") | |
| 651 | return { | |
| 652 | source: "local" as const, | |
| 653 | repo: { owner: where.repo.ownerName, name: where.repo.name }, | |
| 654 | }; | |
| 655 | return { | |
| 656 | source: "origin" as const, | |
| 657 | origin: { | |
| 658 | id: where.origin.id, | |
| 659 | kind: where.origin.kind, | |
| 660 | webUrl: where.origin.webUrl, | |
| 661 | login: where.origin.login, | |
| 662 | }, | |
| 663 | fullName: where.fullName, | |
| 664 | htmlUrl: where.htmlUrl, | |
| 665 | mirroredAs: | |
| 666 | where.mirroredAs === undefined | |
| 667 | ? null | |
| 668 | : { owner: where.mirroredAs.ownerName, name: where.mirroredAs.name }, | |
| 669 | }; | |
| 670 | } | |
| 671 | ||
| 672 | 611 | /** A mirror job, with the repo shaped like every other repo we return. */ |
| 673 | function jobJson(ctx: Ctx, job: MirrorJob) { | |
| 612 | export function jobJson(ctx: Ctx, job: MirrorJob) { | |
| 674 | 613 | return { |
| 675 | 614 | id: job.id, |
| 676 | 615 | originId: job.originId, |
| @@ -718,38 +657,6 @@ export function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) { | ||
| 718 | 657 | }; |
| 719 | 658 | } |
| 720 | 659 | |
| 721 | /** | |
| 722 | * A live read that did not happen. `not_found` keeps its ambiguity; the rest | |
| 723 | * name which side said no, so a client can tell "come back later" from | |
| 724 | * "reconnect this origin". | |
| 725 | */ | |
| 726 | function sourceFailure(error: SourceError): Response { | |
| 727 | switch (error) { | |
| 728 | case "not_found": | |
| 729 | return notFound(); | |
| 730 | case "rate_limited": | |
| 731 | return apiError( | |
| 732 | "rate_limited", | |
| 733 | 429, | |
| 734 | "the origin is rate-limiting us; try again shortly", | |
| 735 | ); | |
| 736 | case "unauthorized": | |
| 737 | return apiError( | |
| 738 | "origin_unauthorized", | |
| 739 | 502, | |
| 740 | "the origin refused this forge's credential; reconnect it", | |
| 741 | ); | |
| 742 | case "too_large": | |
| 743 | return apiError("too_large", 413, "that is too large to read here"); | |
| 744 | case "unreachable": | |
| 745 | return apiError( | |
| 746 | "origin_unreachable", | |
| 747 | 502, | |
| 748 | ORIGIN_UNREACHABLE, | |
| 749 | ); | |
| 750 | } | |
| 751 | } | |
| 752 | ||
| 753 | 660 | /** |
| 754 | 661 | * An action error from a path that may have crossed an origin. The three |
| 755 | 662 | * origin outcomes get codes of their own so a client can tell "the forge |
| @@ -879,7 +786,7 @@ function reviewLines(raw: unknown): ReviewInput[] | undefined { | ||
| 879 | 786 | return out; |
| 880 | 787 | } |
| 881 | 788 | |
| 882 | function editFilesFrom(raw: unknown): EditFile[] { | |
| 789 | export function editFilesFrom(raw: unknown): EditFile[] { | |
| 883 | 790 | if (!Array.isArray(raw)) return []; |
| 884 | 791 | const files: EditFile[] = []; |
| 885 | 792 | for (const entry of raw) { |
| @@ -909,7 +816,7 @@ function editFilesFrom(raw: unknown): EditFile[] { | ||
| 909 | 816 | * `pull.local` is present only when we also mirror the upstream — the pull |
| 910 | 817 | * belongs to the origin either way. |
| 911 | 818 | */ |
| 912 | function contributionJson(ctx: Ctx, result: ContributeResult) { | |
| 819 | export function contributionJson(ctx: Ctx, result: ContributeResult) { | |
| 913 | 820 | return { |
| 914 | 821 | via: result.via, |
| 915 | 822 | repo: { owner: result.fork.ownerName, name: result.fork.name }, |
| @@ -935,7 +842,7 @@ function contributionJson(ctx: Ctx, result: ContributeResult) { | ||
| 935 | 842 | * that failed, the commit that landed anyway); the string ones are the |
| 936 | 843 | * ordinary access and origin codes every other route already speaks. |
| 937 | 844 | */ |
| 938 | function contributeFailure(error: unknown): Response { | |
| 845 | export function contributeFailure(error: unknown): Response { | |
| 939 | 846 | if (typeof error === "object" && error !== null && "kind" in error) { |
| 940 | 847 | const detail = error as { |
| 941 | 848 | kind: string; |
| @@ -1347,73 +1254,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1347 | 1254 | }); |
| 1348 | 1255 | }); |
| 1349 | 1256 | |
| 1350 | router.add("GET", "/api/v1/user", (ctx) => { | |
| 1351 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1352 | const me = ctx.auth.user; | |
| 1353 | if (!me) return unauthorized(); | |
| 1354 | const verified = timePhase(ctx.request, "user", () => emailVerified(ctx.app.db, me.id)); | |
| 1355 | return timePhase(ctx.request, "render", () => json({ ...publicUser(me), emailVerified: verified })); | |
| 1356 | }); | |
| 1357 | ||
| 1358 | // Another verification link for the signed-in account; rate limited. | |
| 1359 | router.add("POST", "/api/v1/user/email/verify", async (ctx) => { | |
| 1360 | if (!ctx.auth.user) return unauthorized(); | |
| 1361 | const sent = await resendVerification(ctx.app, ctx.auth.user); | |
| 1362 | if (sent.ok) | |
| 1363 | return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200); | |
| 1364 | if (sent.error === "unauthorized") return unauthorized(); | |
| 1365 | if (sent.error === "too_soon") | |
| 1366 | return apiError("too_soon", 429, VERIFY_WORDS.too_soon); | |
| 1367 | if (sent.error === "no_mail") | |
| 1368 | return apiError("no_mail", 503, VERIFY_WORDS.no_mail); | |
| 1369 | if (sent.error === "capped") | |
| 1370 | return apiError("capped", 429, CAPPED_WORDS); | |
| 1371 | return apiError("failed", 502, VERIFY_WORDS.failed); | |
| 1372 | }); | |
| 1373 | ||
| 1374 | router.add("PATCH", "/api/v1/user", async (ctx) => { | |
| 1375 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 1376 | if (denied) return denied; | |
| 1377 | const input = await body(ctx.request); | |
| 1378 | if ( | |
| 1379 | input.commitEmail !== undefined && | |
| 1380 | input.commitEmail !== "noreply" && | |
| 1381 | input.commitEmail !== "account" | |
| 1382 | ) | |
| 1383 | return json({ error: "invalid_commit_email" }, 422); | |
| 1384 | const updated = updateProfile(ctx.app, ctx.auth.user, { | |
| 1385 | ...(typeof input.displayName === "string" | |
| 1386 | ? { displayName: input.displayName } | |
| 1387 | : {}), | |
| 1388 | ...(typeof input.email === "string" ? { email: input.email } : {}), | |
| 1389 | ...(input.commitEmail === "noreply" || input.commitEmail === "account" | |
| 1390 | ? { commitEmail: input.commitEmail } | |
| 1391 | : {}), | |
| 1392 | }); | |
| 1393 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 1394 | return json(publicUser(updated.value)); | |
| 1395 | }); | |
| 1396 | ||
| 1397 | router.add("POST", "/api/v1/user/password", async (ctx) => { | |
| 1398 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 1399 | if (denied) return denied; | |
| 1400 | const input = await body(ctx.request); | |
| 1401 | const changed = await changePassword(ctx.app, ctx.auth.user, { | |
| 1402 | current: str(input.current), | |
| 1403 | next: str(input.next), | |
| 1404 | source: clientAddress(ctx.request), | |
| 1405 | }); | |
| 1406 | if (!changed.ok) | |
| 1407 | return respondAccess( | |
| 1408 | changed.error, | |
| 1409 | changed.error === "bad_credentials" | |
| 1410 | ? 403 | |
| 1411 | : changed.error === "throttled" | |
| 1412 | ? 429 | |
| 1413 | : 422, | |
| 1414 | ); | |
| 1415 | return json({}, 200); | |
| 1416 | }); | |
| 1257 | userRoutes(router); | |
| 1417 | 1258 | |
| 1418 | 1259 | // ---- accounts: the owner's view of who joined (#660) ---- |
| 1419 | 1260 | // Hidden from everyone but a forge admin: the door is not there. |
| @@ -1804,48 +1645,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1804 | 1645 | return done.ok ? json({ removed: ctx.params.username }) : accountError(done.error); |
| 1805 | 1646 | }); |
| 1806 | 1647 | |
| 1807 | router.add("GET", "/api/v1/user/orgs", (ctx) => { | |
| 1808 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1809 | if (!ctx.auth.user) return unauthorized(); | |
| 1810 | const user = ctx.auth.user; | |
| 1811 | const window = keyedWindow(ctx); | |
| 1812 | if (window instanceof Response) return window; | |
| 1813 | const orgs = timePhase(ctx.request, "orgs", () => | |
| 1814 | listOrgsForUser(ctx.app.db, user.id).map(publicProfile), | |
| 1815 | ); | |
| 1816 | return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window))); | |
| 1817 | }); | |
| 1818 | ||
| 1819 | // SSH keys — what the SSH listener authenticates against. Public keys are | |
| 1820 | // public, so the list returns them whole. | |
| 1821 | router.add("GET", "/api/v1/user/keys", (ctx) => { | |
| 1822 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1823 | const user = ctx.auth.user; | |
| 1824 | if (!user) return unauthorized(); | |
| 1825 | const window = keyedWindow(ctx); | |
| 1826 | if (window instanceof Response) return window; | |
| 1827 | const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id)); | |
| 1828 | return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window))); | |
| 1829 | }); | |
| 1830 | router.add("POST", "/api/v1/user/keys", async (ctx) => { | |
| 1831 | const denied = requireScope(ctx, "rw"); | |
| 1832 | if (denied) return denied; | |
| 1833 | const input = await body(ctx.request); | |
| 1834 | const added = addSshKey(ctx.app, ctx.auth.user, { | |
| 1835 | name: str(input.name), | |
| 1836 | key: str(input.key), | |
| 1837 | }); | |
| 1838 | if (!added.ok) return respondAccess(added.error, 422); | |
| 1839 | return json(publicKey(added.value), 201); | |
| 1840 | }); | |
| 1841 | router.add("DELETE", "/api/v1/user/keys/:id", (ctx) => { | |
| 1842 | const denied = requireScope(ctx, "rw"); | |
| 1843 | if (denied) return denied; | |
| 1844 | const removed = deleteSshKey(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 1845 | if (!removed.ok) return respondAccess(removed.error, 422); | |
| 1846 | return new Response(null, { status: 204 }); | |
| 1847 | }); | |
| 1848 | ||
| 1849 | 1648 | // Tokens are minted, listed, and revoked with the account itself (a |
| 1850 | 1649 | // session or the password) — never with another token. |
| 1851 | 1650 | router.add("POST", "/api/v1/tokens", async (ctx) => { |
| @@ -4030,454 +3829,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4030 | 3829 | |
| 4031 | 3830 | hookRoutes(router); |
| 4032 | 3831 | |
| 4033 | // ── origins ────────────────────────────────────────────────────────── | |
| 4034 | // | |
| 4035 | // An origin holds a credential for another forge, so every route here is | |
| 4036 | // gated on the *account* — a same-origin session, the password, or an | |
| 4037 | // explicit `admin` token. A leaked `rw` token must never be able to | |
| 4038 | // enumerate, connect, or spend somebody's GitHub access. | |
| 4039 | ||
| 4040 | /** The callback URL this forge tells an OAuth app to come back to. */ | |
| 4041 | const callbackUri = (ctx: Ctx): string => | |
| 4042 | oauthCallbackUri(ctx.url, ctx.app.config.host); | |
| 4043 | ||
| 4044 | router.add("GET", "/api/v1/user/origins", (ctx) => { | |
| 4045 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4046 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4047 | if (denied) return denied; | |
| 4048 | const origins = timePhase(ctx.request, "origins", () => listUserOrigins(ctx.app, ctx.auth.user!).map(originJson)); | |
| 4049 | return timePhase(ctx.request, "render", () => json(origins)); | |
| 4050 | }); | |
| 4051 | ||
| 4052 | router.add("POST", "/api/v1/user/origins", async (ctx) => { | |
| 4053 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4054 | if (denied) return denied; | |
| 4055 | const input = await body(ctx.request); | |
| 4056 | const kind = str(input.kind); | |
| 4057 | const connected = await connectOrigin(ctx.app, ctx.auth.user, { | |
| 4058 | host: str(input.host) || kind, | |
| 4059 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 4060 | ? { kind } | |
| 4061 | : {}), | |
| 4062 | token: str(input.token), | |
| 4063 | }); | |
| 4064 | if (!connected.ok) | |
| 4065 | return apiError( | |
| 4066 | connected.error, | |
| 4067 | connected.error === "rate_limited" ? 429 : 422, | |
| 4068 | connectMessage(connected.error), | |
| 4069 | ); | |
| 4070 | // The frozen mirrors connecting this account brought back, and the ones | |
| 4071 | // it could not, each with its reason. | |
| 4072 | return json( | |
| 4073 | { | |
| 4074 | ...originJson(connected.value), | |
| 4075 | reattached: connected.value.reattached, | |
| 4076 | }, | |
| 4077 | 201, | |
| 4078 | ); | |
| 4079 | }); | |
| 4080 | ||
| 4081 | // OAuth: one start route for both flows, because which flow you get is a | |
| 4082 | // property of the forge (device on GitHub, code+PKCE on Forgejo) and not | |
| 4083 | // something a client should have to know. | |
| 4084 | router.add("POST", "/api/v1/user/origins/oauth", async (ctx) => { | |
| 4085 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4086 | if (denied) return denied; | |
| 4087 | const input = await body(ctx.request); | |
| 4088 | const kind = str(input.kind); | |
| 4089 | const started = await startOAuthConnect(ctx.app, ctx.auth.user, { | |
| 4090 | host: str(input.host) || kind, | |
| 4091 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 4092 | ? { kind } | |
| 4093 | : {}), | |
| 4094 | redirectUri: callbackUri(ctx), | |
| 4095 | }); | |
| 4096 | if (!started.ok) | |
| 4097 | return apiError( | |
| 4098 | started.error.error, | |
| 4099 | started.error.error === "rate_limited" ? 429 : 422, | |
| 4100 | started.error.message, | |
| 4101 | ); | |
| 4102 | return json(started.value, 201); | |
| 4103 | }); | |
| 4104 | ||
| 4105 | router.add("GET", "/api/v1/user/origins/oauth/:id", async (ctx) => { | |
| 4106 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4107 | if (denied) return denied; | |
| 4108 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4109 | const status = await timePhaseAsync(ctx.request, "origin", () => | |
| 4110 | pollOAuthConnect(ctx.app, ctx.auth.user, ctx.params.id!), | |
| 4111 | ); | |
| 4112 | if (!status.ok) return respondAccess(status.error, 404); | |
| 4113 | return json(status.value); | |
| 4114 | }); | |
| 4115 | ||
| 4116 | // Registered OAuth applications. Every Forgejo instance needs its own, so | |
| 4117 | // this is a per-user, per-host registration; GitHub can fall back to one | |
| 4118 | // the operator configured for the whole forge. | |
| 4119 | router.add("GET", "/api/v1/user/origin-apps", (ctx) => { | |
| 4120 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4121 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4122 | if (denied) return denied; | |
| 4123 | const apps = timePhase(ctx.request, "apps", () => listOriginApps(ctx.app, ctx.auth.user!)); | |
| 4124 | return timePhase(ctx.request, "render", () => json(apps)); | |
| 4125 | }); | |
| 4126 | ||
| 4127 | router.add("POST", "/api/v1/user/origin-apps", async (ctx) => { | |
| 4128 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4129 | if (denied) return denied; | |
| 4130 | const input = await body(ctx.request); | |
| 4131 | const kind = str(input.kind); | |
| 4132 | const saved = await registerOriginApp(ctx.app, ctx.auth.user, { | |
| 4133 | host: str(input.host) || kind, | |
| 4134 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 4135 | ? { kind } | |
| 4136 | : {}), | |
| 4137 | clientId: str(input.clientId), | |
| 4138 | ...(typeof input.clientSecret === "string" | |
| 4139 | ? { clientSecret: input.clientSecret } | |
| 4140 | : {}), | |
| 4141 | }); | |
| 4142 | if (!saved.ok) | |
| 4143 | return apiError(saved.error, 422, connectMessage(saved.error)); | |
| 4144 | return json(saved.value, 201); | |
| 4145 | }); | |
| 4146 | ||
| 4147 | router.add("DELETE", "/api/v1/user/origin-apps/:id", (ctx) => { | |
| 4148 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4149 | if (denied) return denied; | |
| 4150 | const removed = deleteOriginApp(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 4151 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 4152 | return new Response(null, { status: 204 }); | |
| 4153 | }); | |
| 4154 | ||
| 4155 | router.add("DELETE", "/api/v1/user/origins/:id", async (ctx) => { | |
| 4156 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4157 | if (denied) return denied; | |
| 4158 | const gone = await disconnectOrigin(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 4159 | if (!gone.ok) return respondAccess(gone.error, 404); | |
| 4160 | return json(gone.value); | |
| 4161 | }); | |
| 4162 | ||
| 4163 | router.add("POST", "/api/v1/user/origins/:id/sync", async (ctx) => { | |
| 4164 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4165 | if (denied) return denied; | |
| 4166 | const synced = await syncOriginNow(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 4167 | if (!synced.ok) return respondAccess(synced.error, 404); | |
| 4168 | return json(synced.value); | |
| 4169 | }); | |
| 4170 | ||
| 4171 | router.add("GET", "/api/v1/user/origins/:id/repos", async (ctx) => { | |
| 4172 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4173 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4174 | if (denied) return denied; | |
| 4175 | const listed = await timePhaseAsync(ctx.request, "origin", () => | |
| 4176 | listRemoteRepos( | |
| 4177 | ctx.app, | |
| 4178 | ctx.auth.user, | |
| 4179 | ctx.params.id!, | |
| 4180 | ctx.url.searchParams.get("refresh") === "1", | |
| 4181 | ), | |
| 4182 | ); | |
| 4183 | if (!listed.ok) | |
| 4184 | return listed.error === "unreachable" | |
| 4185 | ? apiError("origin_unreachable", 502, ORIGIN_UNREACHABLE) | |
| 4186 | : respondAccess(listed.error, 404); | |
| 4187 | return timePhase(ctx.request, "render", () => | |
| 4188 | json(listed.value.map((row) => remoteRepoJson(ctx, row))), | |
| 4189 | ); | |
| 4190 | }); | |
| 4191 | ||
| 4192 | // ── browsing an origin, live ───────────────────────────────────────── | |
| 4193 | // | |
| 4194 | // Reading a repository the origin holds and we do not: the same shapes the | |
| 4195 | // local read routes return, so a client renders one screen either way. | |
| 4196 | // Everything runs through `sourceFor`, which is where the rule lives that | |
| 4197 | // only the person who connected an origin may browse through it — the | |
| 4198 | // request spends their credential on another forge. | |
| 4199 | ||
| 4200 | const browse = (ctx: Ctx): RepoSource | Response => { | |
| 4201 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4202 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4203 | if (denied) return denied; | |
| 4204 | const source = sourceFor(ctx.app, ctx.auth.user, { | |
| 4205 | originId: ctx.params.id!, | |
| 4206 | fullName: `${ctx.params.owner!}/${ctx.params.name!}`, | |
| 4207 | }); | |
| 4208 | return source.ok ? source.value : sourceFailure(source.error); | |
| 4209 | }; | |
| 4210 | ||
| 4211 | /** Run one read against a live source and shape whatever comes back. */ | |
| 4212 | const read = async <T>( | |
| 4213 | ctx: Ctx, | |
| 4214 | run: (source: RepoSource) => Promise<Result<T, SourceError>>, | |
| 4215 | shape: (value: T) => Response, | |
| 4216 | ): Promise<Response> => { | |
| 4217 | const source = browse(ctx); | |
| 4218 | if (source instanceof Response) return source; | |
| 4219 | const result = await timePhaseAsync(ctx.request, "origin", () => run(source)); | |
| 4220 | return result.ok | |
| 4221 | ? timePhase(ctx.request, "render", () => shape(result.value)) | |
| 4222 | : sourceFailure(result.error); | |
| 4223 | }; | |
| 4224 | ||
| 4225 | const BROWSE = "/api/v1/user/origins/:id/browse/:owner/:name"; | |
| 4226 | ||
| 4227 | // The landing read: what a repo page needs before it needs anything else. | |
| 4228 | router.add("GET", BROWSE, async (ctx) => { | |
| 4229 | const source = browse(ctx); | |
| 4230 | if (source instanceof Response) return source; | |
| 4231 | const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe()); | |
| 4232 | if (!facts.ok) return sourceFailure(facts.error); | |
| 4233 | const [branches, readme] = await timePhaseAsync(ctx.request, "origin", () => | |
| 4234 | Promise.all([source.branches(), source.readme(facts.value.defaultBranch)]), | |
| 4235 | ); | |
| 4236 | return timePhase(ctx.request, "render", () => | |
| 4237 | json({ | |
| 4238 | ...whereJson(source), | |
| 4239 | capabilities: source.capabilities, | |
| 4240 | facts: facts.value, | |
| 4241 | branches: branches.ok ? branches.value : [], | |
| 4242 | readme: readme.ok ? readme.value : null, | |
| 4243 | }), | |
| 4244 | ); | |
| 4245 | }); | |
| 4246 | ||
| 4247 | router.add("GET", `${BROWSE}/branches`, (ctx) => | |
| 4248 | read(ctx, (s) => s.branches(), json), | |
| 4249 | ); | |
| 4250 | router.add("GET", `${BROWSE}/tags`, (ctx) => | |
| 4251 | read(ctx, (s) => s.tags(), json), | |
| 4252 | ); | |
| 4253 | router.add("GET", `${BROWSE}/labels`, (ctx) => | |
| 4254 | read(ctx, (s) => s.labels(), json), | |
| 4255 | ); | |
| 4256 | router.add("GET", `${BROWSE}/paths/:ref`, (ctx) => | |
| 4257 | read(ctx, (s) => s.paths(ctx.params.ref!), json), | |
| 4258 | ); | |
| 4259 | ||
| 4260 | const treeHandler = (ctx: Ctx): Promise<Response> => { | |
| 4261 | const ref = ctx.params.ref!; | |
| 4262 | const path = ctx.params.path ?? ""; | |
| 4263 | return read( | |
| 4264 | ctx, | |
| 4265 | (s) => s.tree(ref, path), | |
| 4266 | (entries) => json({ type: "dir", ref, path, entries }), | |
| 4267 | ); | |
| 4268 | }; | |
| 4269 | router.add("GET", `${BROWSE}/tree/:ref`, treeHandler); | |
| 4270 | router.add("GET", `${BROWSE}/tree/:ref/*path`, treeHandler); | |
| 4271 | ||
| 4272 | // Shaped exactly like the local contents endpoint, down to the 413. | |
| 4273 | router.add("GET", `${BROWSE}/blob/:ref/*path`, (ctx) => { | |
| 4274 | const ref = ctx.params.ref!; | |
| 4275 | const path = ctx.params.path ?? ""; | |
| 4276 | return read( | |
| 4277 | ctx, | |
| 4278 | (s) => s.blob(ref, path), | |
| 4279 | (blob) => { | |
| 4280 | if (blob.kind === "too_large") | |
| 4281 | return json({ type: "file", tooLarge: true, size: blob.size }, 413); | |
| 4282 | const binary = blob.kind === "binary"; | |
| 4283 | return json({ | |
| 4284 | type: "file", | |
| 4285 | ref, | |
| 4286 | path, | |
| 4287 | size: blob.size, | |
| 4288 | encoding: binary ? "base64" : "utf-8", | |
| 4289 | content: binary | |
| 4290 | ? Buffer.from(blob.body).toString("base64") | |
| 4291 | : new TextDecoder().decode(blob.body), | |
| 4292 | }); | |
| 4293 | }, | |
| 4294 | ); | |
| 4295 | }); | |
| 4296 | ||
| 4297 | router.add("GET", `${BROWSE}/commits`, async (ctx) => { | |
| 4298 | const source = browse(ctx); | |
| 4299 | if (source instanceof Response) return source; | |
| 4300 | // Only ask what the default branch is when the caller did not say — on | |
| 4301 | // a live source that question is a request to another forge. | |
| 4302 | let ref = ctx.url.searchParams.get("ref") ?? ""; | |
| 4303 | if (ref === "") { | |
| 4304 | const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe()); | |
| 4305 | if (!facts.ok) return sourceFailure(facts.error); | |
| 4306 | ref = facts.value.defaultBranch || "HEAD"; | |
| 4307 | } | |
| 4308 | const cursor = ctx.url.searchParams.get("cursor") ?? undefined; | |
| 4309 | const page = await timePhaseAsync(ctx.request, "origin", () => source.commits(ref, cursor)); | |
| 4310 | if (!page.ok) return sourceFailure(page.error); | |
| 4311 | return timePhase(ctx.request, "render", () => | |
| 4312 | json({ items: page.value.items, next_cursor: page.value.next }), | |
| 4313 | ); | |
| 4314 | }); | |
| 4315 | ||
| 4316 | router.add("GET", `${BROWSE}/commits/:oid`, (ctx) => { | |
| 4317 | const oid = ctx.params.oid!; | |
| 4318 | return read( | |
| 4319 | ctx, | |
| 4320 | (s) => s.commit(oid), | |
| 4321 | ({ commit, diff }) => json({ ...commit, diff }), | |
| 4322 | ); | |
| 4323 | }); | |
| 4324 | ||
| 4325 | const conversations = (kind: "issue" | "pull") => async (ctx: Ctx) => { | |
| 4326 | const q = ctx.url.searchParams; | |
| 4327 | const stateParam = q.get("state"); | |
| 4328 | const label = q.get("label"); | |
| 4329 | const author = q.get("author"); | |
| 4330 | const search = q.get("q"); | |
| 4331 | const cursor = q.get("cursor"); | |
| 4332 | return read( | |
| 4333 | ctx, | |
| 4334 | (s) => | |
| 4335 | s.issues({ | |
| 4336 | kind, | |
| 4337 | state: | |
| 4338 | stateParam === "closed" || stateParam === "all" | |
| 4339 | ? stateParam | |
| 4340 | : "open", | |
| 4341 | ...(label ? { label } : {}), | |
| 4342 | ...(author ? { author } : {}), | |
| 4343 | ...(search ? { query: search } : {}), | |
| 4344 | ...(cursor ? { cursor } : {}), | |
| 4345 | }), | |
| 4346 | (page) => json({ items: page.items, next_cursor: page.next }), | |
| 4347 | ); | |
| 4348 | }; | |
| 4349 | router.add("GET", `${BROWSE}/issues`, conversations("issue")); | |
| 4350 | router.add("GET", `${BROWSE}/pulls`, conversations("pull")); | |
| 4351 | ||
| 4352 | const conversation = (ctx: Ctx): Promise<Response> => { | |
| 4353 | const number = Number(ctx.params.number); | |
| 4354 | return read( | |
| 4355 | ctx, | |
| 4356 | (s) => s.issue(number), | |
| 4357 | (detail) => | |
| 4358 | json({ | |
| 4359 | ...detail.issue, | |
| 4360 | body: detail.body, | |
| 4361 | comments: detail.comments, | |
| 4362 | pull: detail.pull, | |
| 4363 | }), | |
| 4364 | ); | |
| 4365 | }; | |
| 4366 | router.add("GET", `${BROWSE}/issues/:number`, conversation); | |
| 4367 | router.add("GET", `${BROWSE}/pulls/:number`, conversation); | |
| 4368 | ||
| 4369 | // ── mirroring, as a job ────────────────────────────────────────────── | |
| 4370 | // | |
| 4371 | // 202, not 201: the repository does not exist yet. What comes back is | |
| 4372 | // something to watch — poll it, or take the stream and render the stages. | |
| 4373 | ||
| 4374 | router.add("POST", "/api/v1/user/origins/:id/mirror", async (ctx) => { | |
| 4375 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4376 | if (denied) return denied; | |
| 4377 | const input = await body(ctx.request); | |
| 4378 | const started = startMirrorJob( | |
| 4379 | ctx.app, | |
| 4380 | ctx.auth.user, | |
| 4381 | ctx.params.id!, | |
| 4382 | str(input.fullName), | |
| 4383 | str(input.name) || undefined, | |
| 4384 | ); | |
| 4385 | if (!started.ok) return respondAccess(started.error, 404); | |
| 4386 | return json({ job: jobJson(ctx, started.value) }, 202); | |
| 4387 | }); | |
| 4388 | ||
| 4389 | // Fork at the origin. 202, like mirroring: the fork is asynchronous on | |
| 4390 | // GitHub, and the local copy of it is a job to watch. | |
| 4391 | router.add("POST", "/api/v1/user/origins/:id/fork", async (ctx) => { | |
| 4392 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4393 | if (denied) return denied; | |
| 4394 | const input = await body(ctx.request); | |
| 4395 | const forked = await forkOrigin( | |
| 4396 | ctx.app, | |
| 4397 | ctx.auth.user, | |
| 4398 | ctx.params.id!, | |
| 4399 | str(input.fullName), | |
| 4400 | ); | |
| 4401 | if (!forked.ok) return contributeFailure(forked.error); | |
| 4402 | return json( | |
| 4403 | { | |
| 4404 | remote: forked.value.remote, | |
| 4405 | existed: forked.value.existed, | |
| 4406 | job: | |
| 4407 | forked.value.job === undefined | |
| 4408 | ? null | |
| 4409 | : jobJson(ctx, forked.value.job), | |
| 4410 | mirroredAs: | |
| 4411 | forked.value.mirror === undefined | |
| 4412 | ? null | |
| 4413 | : { | |
| 4414 | owner: forked.value.mirror.ownerName, | |
| 4415 | name: forked.value.mirror.name, | |
| 4416 | }, | |
| 4417 | }, | |
| 4418 | 202, | |
| 4419 | ); | |
| 4420 | }); | |
| 4421 | ||
| 4422 | // Contribute to a repository we are only *browsing* — the fork flow | |
| 4423 | // straight from the picker, with no mirror of the upstream first. | |
| 4424 | router.add("POST", `${BROWSE}/contribute`, async (ctx) => { | |
| 4425 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4426 | if (denied) return denied; | |
| 4427 | const input = await body(ctx.request); | |
| 4428 | const result = await contribute(ctx.app, ctx.auth.user, { | |
| 4429 | originId: ctx.params.id!, | |
| 4430 | fullName: `${ctx.params.owner!}/${ctx.params.name!}`, | |
| 4431 | branch: str(input.branch), | |
| 4432 | ...(str(input.base) === "" ? {} : { base: str(input.base) }), | |
| 4433 | ...(str(input.message) === "" ? {} : { message: str(input.message) }), | |
| 4434 | files: editFilesFrom(input.files), | |
| 4435 | ...(typeof input.openPull === "object" && input.openPull !== null | |
| 4436 | ? { | |
| 4437 | openPull: { | |
| 4438 | title: str((input.openPull as Record<string, unknown>).title), | |
| 4439 | body: str((input.openPull as Record<string, unknown>).body), | |
| 4440 | }, | |
| 4441 | } | |
| 4442 | : {}), | |
| 4443 | }); | |
| 4444 | if (!result.ok) return contributeFailure(result.error); | |
| 4445 | return json(contributionJson(ctx, result.value), 201); | |
| 4446 | }); | |
| 4447 | ||
| 4448 | router.add("GET", "/api/v1/user/origins/:id/mirror/:job", (ctx) => { | |
| 4449 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4450 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4451 | if (denied) return denied; | |
| 4452 | const job = timePhase(ctx.request, "job", () => | |
| 4453 | mirrorJobFor(ctx.app, ctx.auth.user, ctx.params.id!, ctx.params.job!), | |
| 4454 | ); | |
| 4455 | if (!job.ok) return respondAccess(job.error, 404); | |
| 4456 | return timePhase(ctx.request, "render", () => | |
| 4457 | json({ job: jobJson(ctx, job.value) }), | |
| 4458 | ); | |
| 4459 | }); | |
| 4460 | ||
| 4461 | // The same job as a live stream, so a mirror can be watched rather than | |
| 4462 | // polled. It ends itself the moment the job reaches a terminal stage. | |
| 4463 | router.add("GET", "/api/v1/user/origins/:id/mirror/:job/stream", (ctx) => { | |
| 4464 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 4465 | if (denied) return denied; | |
| 4466 | const found = mirrorJobFor( | |
| 4467 | ctx.app, | |
| 4468 | ctx.auth.user, | |
| 4469 | ctx.params.id!, | |
| 4470 | ctx.params.job!, | |
| 4471 | ); | |
| 4472 | if (!found.ok) return respondAccess(found.error, 404); | |
| 4473 | return eventStream(ctx.app, ctx.request, (emit, close) => | |
| 4474 | subscribeJob(found.value.id, (job) => { | |
| 4475 | emit(`mirror.${job.stage}`, jobJson(ctx, job), job.id); | |
| 4476 | if (job.stage === "done" || job.stage === "failed") close(); | |
| 4477 | }), | |
| 4478 | ); | |
| 4479 | }); | |
| 4480 | ||
| 4481 | 3832 | // Sync one mirror, or let a fork take its parent's updates. A mirror only |
| 4482 | 3833 | // pulls, so any writer may ask. A fork fast-forwards, or writes a merge |
| 4483 | 3834 | // commit authored as the caller when it has work of its own and the merge |
| @@ -4566,58 +3917,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4566 | 3917 | return json(op.value, 201); |
| 4567 | 3918 | }); |
| 4568 | 3919 | |
| 4569 | // ── pins ───────────────────────────────────────────────────────────── | |
| 4570 | ||
| 4571 | // The local client clones from this list, so each pin carries what a | |
| 4572 | // clone needs: both URLs and where the repo came from. | |
| 4573 | router.add("GET", "/api/v1/user/pins", (ctx) => { | |
| 4574 | if (!ctx.auth.user) return unauthorized(); | |
| 4575 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4576 | const window = keyedWindow(ctx); | |
| 4577 | if (window instanceof Response) return window; | |
| 4578 | const user = ctx.auth.user; | |
| 4579 | const { pins, repos } = timePhase(ctx.request, "pins", () => { | |
| 4580 | const pins = listPinned(ctx.app, user); | |
| 4581 | // One query for every pinned repo, not one per pin. | |
| 4582 | const repos = getReposByIds( | |
| 4583 | ctx.app.db, | |
| 4584 | pins.map((pin) => pin.repoId), | |
| 4585 | ); | |
| 4586 | return { pins, repos }; | |
| 4587 | }); | |
| 4588 | return timePhase(ctx.request, "render", () => { | |
| 4589 | const items = pins.flatMap((pin) => { | |
| 4590 | const repo = repos.get(pin.repoId); | |
| 4591 | if (!repo) return []; | |
| 4592 | const path = `${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`; | |
| 4593 | return [ | |
| 4594 | { | |
| 4595 | repoId: repo.id, | |
| 4596 | owner: repo.ownerName, | |
| 4597 | name: repo.name, | |
| 4598 | defaultBranch: repo.defaultBranch, | |
| 4599 | cloneUrl: `${ctx.url.origin}/${path}.git`, | |
| 4600 | sshUrl: | |
| 4601 | sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null, | |
| 4602 | origin: originFacts(repo), | |
| 4603 | pinnedAt: pin.createdAt, | |
| 4604 | }, | |
| 4605 | ]; | |
| 4606 | }); | |
| 4607 | return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window)); | |
| 4608 | }); | |
| 4609 | }); | |
| 4610 | ||
| 4611 | router.add("PUT", "/api/v1/user/pins/:owner/:name", (ctx) => { | |
| 4612 | const denied = requireScope(ctx, "rw"); | |
| 4613 | if (denied) return denied; | |
| 4614 | const repo = loadRepo(ctx); | |
| 4615 | if (!repo) return notFound(); | |
| 4616 | const pinned = pinRepo(ctx.app, ctx.auth.user, repo); | |
| 4617 | if (!pinned.ok) return respondAccess(pinned.error, 404); | |
| 4618 | return json({ pinned: true }); | |
| 4619 | }); | |
| 4620 | ||
| 4621 | 3920 | // ---- apps, runs, and the crew (docs/APPS.md) ---- |
| 4622 | 3921 | |
| 4623 | 3922 | // How the forge is: anyone may ask (a monitor has no account), a stranger |
| @@ -5602,33 +4901,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5602 | 4901 | ); |
| 5603 | 4902 | } |
| 5604 | 4903 | |
| 5605 | // What is on the caller: open issues and pulls assigned to them, anywhere. | |
| 5606 | router.add("GET", "/api/v1/user/assigned", (ctx) => { | |
| 5607 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 5608 | const denied = requireScope(ctx, "ro"); | |
| 5609 | if (denied) return denied; | |
| 5610 | const user = ctx.auth.user!; | |
| 5611 | const items = timePhase(ctx.request, "assigned", () => { | |
| 5612 | const issues = listAssignedTo(ctx.app.db, user.id); | |
| 5613 | const repos = getReposByIds( | |
| 5614 | ctx.app.db, | |
| 5615 | issues.map((i) => i.repoId), | |
| 5616 | ); | |
| 5617 | const out = []; | |
| 5618 | for (const issue of issues) { | |
| 5619 | const repo = repos.get(issue.repoId); | |
| 5620 | if (!repo || !canRead(ctx.app.db, repo, user)) continue; | |
| 5621 | if (!mayView(ctx.app.db, repo, issue, user)) continue; | |
| 5622 | out.push({ | |
| 5623 | ...issue, | |
| 5624 | repo: { owner: repo.ownerName, name: repo.name }, | |
| 5625 | }); | |
| 5626 | } | |
| 5627 | return out; | |
| 5628 | }); | |
| 5629 | return timePhase(ctx.request, "render", () => json({ items, next_cursor: null })); | |
| 5630 | }); | |
| 5631 | ||
| 5632 | 4904 | agentRoutes(router); |
| 5633 | 4905 | |
| 5634 | 4906 | router.add("GET", "/api/v1/repos/:owner/:name/crew", (ctx) => { |
| @@ -5671,16 +4943,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5671 | 4943 | if (!removed.ok) return respondAccess(removed.error, 422); |
| 5672 | 4944 | return json({}, 200); |
| 5673 | 4945 | }); |
| 5674 | ||
| 5675 | router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => { | |
| 5676 | const denied = requireScope(ctx, "rw"); | |
| 5677 | if (denied) return denied; | |
| 5678 | const repo = loadRepo(ctx); | |
| 5679 | if (!repo) return notFound(); | |
| 5680 | const removed = unpinRepo(ctx.app, ctx.auth.user, repo); | |
| 5681 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 5682 | return new Response(null, { status: 204 }); | |
| 5683 | }); | |
| 5684 | 4946 | } |
| 5685 | 4947 | |
| 5686 | 4948 | /** |
src/http/api/user.ts added +741
| @@ -0,0 +1,741 @@ | ||
| 1 | /** | |
| 2 | * The signed-in account over the API: every `/api/v1/user…` route — the | |
| 3 | * profile, password and email verification, its orgs and SSH keys, origins | |
| 4 | * (connecting, OAuth, browsing live, mirroring and forking), pins, and what | |
| 5 | * is assigned to the caller. Moved out of `api.ts` (#7444), unchanged. | |
| 6 | */ | |
| 7 | ||
| 8 | import { contribute, forkOrigin } from "../../actions/contribute.ts"; | |
| 9 | import { CAPPED_WORDS } from "../../actions/mail.ts"; | |
| 10 | import { ORIGIN_UNREACHABLE } from "../../actions/origin_app_words.ts"; | |
| 11 | import { connectOrigin, deleteOriginApp, disconnectOrigin, listOriginApps, listPinned, listRemoteRepos, listUserOrigins, mirrorJobFor, pinRepo, pollOAuthConnect, registerOriginApp, startMirrorJob, startOAuthConnect, syncOriginNow, unpinRepo } from "../../actions/origins.ts"; | |
| 12 | import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts"; | |
| 13 | import { VERIFY_WORDS } from "../../actions/verify_words.ts"; | |
| 14 | import { resendVerification } from "../../actions/verify.ts"; | |
| 15 | import { listAssignedTo } from "../../core/issues.ts"; | |
| 16 | import { listKeys, publicKey } from "../../core/keys.ts"; | |
| 17 | import { listOrgsForUser } from "../../core/orgs.ts"; | |
| 18 | import { canRead, getReposByIds } from "../../core/repos.ts"; | |
| 19 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 20 | import { mayView } from "../../core/sealed.ts"; | |
| 21 | import { emailVerified } from "../../core/verify.ts"; | |
| 22 | import type { Result } from "../../kernel/result.ts"; | |
| 23 | import { subscribe as subscribeJob } from "../../origins/jobs.ts"; | |
| 24 | import { sshCloneUrl } from "../../ssh/command.ts"; | |
| 25 | import { type RepoSource, type SourceError, sourceFor } from "../../view/source.ts"; | |
| 26 | import { connectMessage, contributeFailure, contributionJson, editFilesFrom, jobJson, keyedPage, keyedWindow, loadRepo, oauthCallbackUri, originFacts, originJson, remoteRepoJson } from "../api.ts"; | |
| 27 | import type { Ctx, Handler } from "../context.ts"; | |
| 28 | import { clientAddress } from "../peer.ts"; | |
| 29 | import { apiError, body, json, notFound, publicProfile, publicUser, requireAccount, requireScope, respondAccess, str, unauthorized } from "../respond.ts"; | |
| 30 | import type { Router } from "../router.ts"; | |
| 31 | import { eventStream } from "../sse.ts"; | |
| 32 | ||
| 33 | /** | |
| 34 | * Where a live browse is reading from — the banner's facts. The origin's | |
| 35 | * credential and its id are ours; what a client gets is the forge, the name | |
| 36 | * there, the page there, and whether we already hold a mirror of it. | |
| 37 | */ | |
| 38 | function whereJson(source: RepoSource) { | |
| 39 | const where = source.where; | |
| 40 | if (where.kind === "local") | |
| 41 | return { | |
| 42 | source: "local" as const, | |
| 43 | repo: { owner: where.repo.ownerName, name: where.repo.name }, | |
| 44 | }; | |
| 45 | return { | |
| 46 | source: "origin" as const, | |
| 47 | origin: { | |
| 48 | id: where.origin.id, | |
| 49 | kind: where.origin.kind, | |
| 50 | webUrl: where.origin.webUrl, | |
| 51 | login: where.origin.login, | |
| 52 | }, | |
| 53 | fullName: where.fullName, | |
| 54 | htmlUrl: where.htmlUrl, | |
| 55 | mirroredAs: | |
| 56 | where.mirroredAs === undefined | |
| 57 | ? null | |
| 58 | : { owner: where.mirroredAs.ownerName, name: where.mirroredAs.name }, | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * A live read that did not happen. `not_found` keeps its ambiguity; the rest | |
| 64 | * name which side said no, so a client can tell "come back later" from | |
| 65 | * "reconnect this origin". | |
| 66 | */ | |
| 67 | function sourceFailure(error: SourceError): Response { | |
| 68 | switch (error) { | |
| 69 | case "not_found": | |
| 70 | return notFound(); | |
| 71 | case "rate_limited": | |
| 72 | return apiError( | |
| 73 | "rate_limited", | |
| 74 | 429, | |
| 75 | "the origin is rate-limiting us; try again shortly", | |
| 76 | ); | |
| 77 | case "unauthorized": | |
| 78 | return apiError( | |
| 79 | "origin_unauthorized", | |
| 80 | 502, | |
| 81 | "the origin refused this forge's credential; reconnect it", | |
| 82 | ); | |
| 83 | case "too_large": | |
| 84 | return apiError("too_large", 413, "that is too large to read here"); | |
| 85 | case "unreachable": | |
| 86 | return apiError( | |
| 87 | "origin_unreachable", | |
| 88 | 502, | |
| 89 | ORIGIN_UNREACHABLE, | |
| 90 | ); | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | export function userRoutes(router: Router<Handler>): void { | |
| 95 | router.add("GET", "/api/v1/user", (ctx) => { | |
| 96 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 97 | const me = ctx.auth.user; | |
| 98 | if (!me) return unauthorized(); | |
| 99 | const verified = timePhase(ctx.request, "user", () => emailVerified(ctx.app.db, me.id)); | |
| 100 | return timePhase(ctx.request, "render", () => json({ ...publicUser(me), emailVerified: verified })); | |
| 101 | }); | |
| 102 | ||
| 103 | // Another verification link for the signed-in account; rate limited. | |
| 104 | router.add("POST", "/api/v1/user/email/verify", async (ctx) => { | |
| 105 | if (!ctx.auth.user) return unauthorized(); | |
| 106 | const sent = await resendVerification(ctx.app, ctx.auth.user); | |
| 107 | if (sent.ok) | |
| 108 | return json({ outcome: sent.value }, sent.value === "sent" ? 202 : 200); | |
| 109 | if (sent.error === "unauthorized") return unauthorized(); | |
| 110 | if (sent.error === "too_soon") | |
| 111 | return apiError("too_soon", 429, VERIFY_WORDS.too_soon); | |
| 112 | if (sent.error === "no_mail") | |
| 113 | return apiError("no_mail", 503, VERIFY_WORDS.no_mail); | |
| 114 | if (sent.error === "capped") | |
| 115 | return apiError("capped", 429, CAPPED_WORDS); | |
| 116 | return apiError("failed", 502, VERIFY_WORDS.failed); | |
| 117 | }); | |
| 118 | ||
| 119 | router.add("PATCH", "/api/v1/user", async (ctx) => { | |
| 120 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 121 | if (denied) return denied; | |
| 122 | const input = await body(ctx.request); | |
| 123 | if ( | |
| 124 | input.commitEmail !== undefined && | |
| 125 | input.commitEmail !== "noreply" && | |
| 126 | input.commitEmail !== "account" | |
| 127 | ) | |
| 128 | return json({ error: "invalid_commit_email" }, 422); | |
| 129 | const updated = updateProfile(ctx.app, ctx.auth.user, { | |
| 130 | ...(typeof input.displayName === "string" | |
| 131 | ? { displayName: input.displayName } | |
| 132 | : {}), | |
| 133 | ...(typeof input.email === "string" ? { email: input.email } : {}), | |
| 134 | ...(input.commitEmail === "noreply" || input.commitEmail === "account" | |
| 135 | ? { commitEmail: input.commitEmail } | |
| 136 | : {}), | |
| 137 | }); | |
| 138 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 139 | return json(publicUser(updated.value)); | |
| 140 | }); | |
| 141 | ||
| 142 | router.add("POST", "/api/v1/user/password", async (ctx) => { | |
| 143 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 144 | if (denied) return denied; | |
| 145 | const input = await body(ctx.request); | |
| 146 | const changed = await changePassword(ctx.app, ctx.auth.user, { | |
| 147 | current: str(input.current), | |
| 148 | next: str(input.next), | |
| 149 | source: clientAddress(ctx.request), | |
| 150 | }); | |
| 151 | if (!changed.ok) | |
| 152 | return respondAccess( | |
| 153 | changed.error, | |
| 154 | changed.error === "bad_credentials" | |
| 155 | ? 403 | |
| 156 | : changed.error === "throttled" | |
| 157 | ? 429 | |
| 158 | : 422, | |
| 159 | ); | |
| 160 | return json({}, 200); | |
| 161 | }); | |
| 162 | ||
| 163 | router.add("GET", "/api/v1/user/orgs", (ctx) => { | |
| 164 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 165 | if (!ctx.auth.user) return unauthorized(); | |
| 166 | const user = ctx.auth.user; | |
| 167 | const window = keyedWindow(ctx); | |
| 168 | if (window instanceof Response) return window; | |
| 169 | const orgs = timePhase(ctx.request, "orgs", () => | |
| 170 | listOrgsForUser(ctx.app.db, user.id).map(publicProfile), | |
| 171 | ); | |
| 172 | return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window))); | |
| 173 | }); | |
| 174 | ||
| 175 | // SSH keys — what the SSH listener authenticates against. Public keys are | |
| 176 | // public, so the list returns them whole. | |
| 177 | router.add("GET", "/api/v1/user/keys", (ctx) => { | |
| 178 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 179 | const user = ctx.auth.user; | |
| 180 | if (!user) return unauthorized(); | |
| 181 | const window = keyedWindow(ctx); | |
| 182 | if (window instanceof Response) return window; | |
| 183 | const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id)); | |
| 184 | return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window))); | |
| 185 | }); | |
| 186 | router.add("POST", "/api/v1/user/keys", async (ctx) => { | |
| 187 | const denied = requireScope(ctx, "rw"); | |
| 188 | if (denied) return denied; | |
| 189 | const input = await body(ctx.request); | |
| 190 | const added = addSshKey(ctx.app, ctx.auth.user, { | |
| 191 | name: str(input.name), | |
| 192 | key: str(input.key), | |
| 193 | }); | |
| 194 | if (!added.ok) return respondAccess(added.error, 422); | |
| 195 | return json(publicKey(added.value), 201); | |
| 196 | }); | |
| 197 | router.add("DELETE", "/api/v1/user/keys/:id", (ctx) => { | |
| 198 | const denied = requireScope(ctx, "rw"); | |
| 199 | if (denied) return denied; | |
| 200 | const removed = deleteSshKey(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 201 | if (!removed.ok) return respondAccess(removed.error, 422); | |
| 202 | return new Response(null, { status: 204 }); | |
| 203 | }); | |
| 204 | ||
| 205 | // ── origins ────────────────────────────────────────────────────────── | |
| 206 | // | |
| 207 | // An origin holds a credential for another forge, so every route here is | |
| 208 | // gated on the *account* — a same-origin session, the password, or an | |
| 209 | // explicit `admin` token. A leaked `rw` token must never be able to | |
| 210 | // enumerate, connect, or spend somebody's GitHub access. | |
| 211 | ||
| 212 | /** The callback URL this forge tells an OAuth app to come back to. */ | |
| 213 | const callbackUri = (ctx: Ctx): string => | |
| 214 | oauthCallbackUri(ctx.url, ctx.app.config.host); | |
| 215 | ||
| 216 | router.add("GET", "/api/v1/user/origins", (ctx) => { | |
| 217 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 218 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 219 | if (denied) return denied; | |
| 220 | const origins = timePhase(ctx.request, "origins", () => listUserOrigins(ctx.app, ctx.auth.user!).map(originJson)); | |
| 221 | return timePhase(ctx.request, "render", () => json(origins)); | |
| 222 | }); | |
| 223 | ||
| 224 | router.add("POST", "/api/v1/user/origins", async (ctx) => { | |
| 225 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 226 | if (denied) return denied; | |
| 227 | const input = await body(ctx.request); | |
| 228 | const kind = str(input.kind); | |
| 229 | const connected = await connectOrigin(ctx.app, ctx.auth.user, { | |
| 230 | host: str(input.host) || kind, | |
| 231 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 232 | ? { kind } | |
| 233 | : {}), | |
| 234 | token: str(input.token), | |
| 235 | }); | |
| 236 | if (!connected.ok) | |
| 237 | return apiError( | |
| 238 | connected.error, | |
| 239 | connected.error === "rate_limited" ? 429 : 422, | |
| 240 | connectMessage(connected.error), | |
| 241 | ); | |
| 242 | // The frozen mirrors connecting this account brought back, and the ones | |
| 243 | // it could not, each with its reason. | |
| 244 | return json( | |
| 245 | { | |
| 246 | ...originJson(connected.value), | |
| 247 | reattached: connected.value.reattached, | |
| 248 | }, | |
| 249 | 201, | |
| 250 | ); | |
| 251 | }); | |
| 252 | ||
| 253 | // OAuth: one start route for both flows, because which flow you get is a | |
| 254 | // property of the forge (device on GitHub, code+PKCE on Forgejo) and not | |
| 255 | // something a client should have to know. | |
| 256 | router.add("POST", "/api/v1/user/origins/oauth", async (ctx) => { | |
| 257 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 258 | if (denied) return denied; | |
| 259 | const input = await body(ctx.request); | |
| 260 | const kind = str(input.kind); | |
| 261 | const started = await startOAuthConnect(ctx.app, ctx.auth.user, { | |
| 262 | host: str(input.host) || kind, | |
| 263 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 264 | ? { kind } | |
| 265 | : {}), | |
| 266 | redirectUri: callbackUri(ctx), | |
| 267 | }); | |
| 268 | if (!started.ok) | |
| 269 | return apiError( | |
| 270 | started.error.error, | |
| 271 | started.error.error === "rate_limited" ? 429 : 422, | |
| 272 | started.error.message, | |
| 273 | ); | |
| 274 | return json(started.value, 201); | |
| 275 | }); | |
| 276 | ||
| 277 | router.add("GET", "/api/v1/user/origins/oauth/:id", async (ctx) => { | |
| 278 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 279 | if (denied) return denied; | |
| 280 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 281 | const status = await timePhaseAsync(ctx.request, "origin", () => | |
| 282 | pollOAuthConnect(ctx.app, ctx.auth.user, ctx.params.id!), | |
| 283 | ); | |
| 284 | if (!status.ok) return respondAccess(status.error, 404); | |
| 285 | return json(status.value); | |
| 286 | }); | |
| 287 | ||
| 288 | // Registered OAuth applications. Every Forgejo instance needs its own, so | |
| 289 | // this is a per-user, per-host registration; GitHub can fall back to one | |
| 290 | // the operator configured for the whole forge. | |
| 291 | router.add("GET", "/api/v1/user/origin-apps", (ctx) => { | |
| 292 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 293 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 294 | if (denied) return denied; | |
| 295 | const apps = timePhase(ctx.request, "apps", () => listOriginApps(ctx.app, ctx.auth.user!)); | |
| 296 | return timePhase(ctx.request, "render", () => json(apps)); | |
| 297 | }); | |
| 298 | ||
| 299 | router.add("POST", "/api/v1/user/origin-apps", async (ctx) => { | |
| 300 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 301 | if (denied) return denied; | |
| 302 | const input = await body(ctx.request); | |
| 303 | const kind = str(input.kind); | |
| 304 | const saved = await registerOriginApp(ctx.app, ctx.auth.user, { | |
| 305 | host: str(input.host) || kind, | |
| 306 | ...(kind === "github" || kind === "forgejo" || kind === "gitea" | |
| 307 | ? { kind } | |
| 308 | : {}), | |
| 309 | clientId: str(input.clientId), | |
| 310 | ...(typeof input.clientSecret === "string" | |
| 311 | ? { clientSecret: input.clientSecret } | |
| 312 | : {}), | |
| 313 | }); | |
| 314 | if (!saved.ok) | |
| 315 | return apiError(saved.error, 422, connectMessage(saved.error)); | |
| 316 | return json(saved.value, 201); | |
| 317 | }); | |
| 318 | ||
| 319 | router.add("DELETE", "/api/v1/user/origin-apps/:id", (ctx) => { | |
| 320 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 321 | if (denied) return denied; | |
| 322 | const removed = deleteOriginApp(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 323 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 324 | return new Response(null, { status: 204 }); | |
| 325 | }); | |
| 326 | ||
| 327 | router.add("DELETE", "/api/v1/user/origins/:id", async (ctx) => { | |
| 328 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 329 | if (denied) return denied; | |
| 330 | const gone = await disconnectOrigin(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 331 | if (!gone.ok) return respondAccess(gone.error, 404); | |
| 332 | return json(gone.value); | |
| 333 | }); | |
| 334 | ||
| 335 | router.add("POST", "/api/v1/user/origins/:id/sync", async (ctx) => { | |
| 336 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 337 | if (denied) return denied; | |
| 338 | const synced = await syncOriginNow(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 339 | if (!synced.ok) return respondAccess(synced.error, 404); | |
| 340 | return json(synced.value); | |
| 341 | }); | |
| 342 | ||
| 343 | router.add("GET", "/api/v1/user/origins/:id/repos", async (ctx) => { | |
| 344 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 345 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 346 | if (denied) return denied; | |
| 347 | const listed = await timePhaseAsync(ctx.request, "origin", () => | |
| 348 | listRemoteRepos( | |
| 349 | ctx.app, | |
| 350 | ctx.auth.user, | |
| 351 | ctx.params.id!, | |
| 352 | ctx.url.searchParams.get("refresh") === "1", | |
| 353 | ), | |
| 354 | ); | |
| 355 | if (!listed.ok) | |
| 356 | return listed.error === "unreachable" | |
| 357 | ? apiError("origin_unreachable", 502, ORIGIN_UNREACHABLE) | |
| 358 | : respondAccess(listed.error, 404); | |
| 359 | return timePhase(ctx.request, "render", () => | |
| 360 | json(listed.value.map((row) => remoteRepoJson(ctx, row))), | |
| 361 | ); | |
| 362 | }); | |
| 363 | ||
| 364 | // ── browsing an origin, live ───────────────────────────────────────── | |
| 365 | // | |
| 366 | // Reading a repository the origin holds and we do not: the same shapes the | |
| 367 | // local read routes return, so a client renders one screen either way. | |
| 368 | // Everything runs through `sourceFor`, which is where the rule lives that | |
| 369 | // only the person who connected an origin may browse through it — the | |
| 370 | // request spends their credential on another forge. | |
| 371 | ||
| 372 | const browse = (ctx: Ctx): RepoSource | Response => { | |
| 373 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 374 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 375 | if (denied) return denied; | |
| 376 | const source = sourceFor(ctx.app, ctx.auth.user, { | |
| 377 | originId: ctx.params.id!, | |
| 378 | fullName: `${ctx.params.owner!}/${ctx.params.name!}`, | |
| 379 | }); | |
| 380 | return source.ok ? source.value : sourceFailure(source.error); | |
| 381 | }; | |
| 382 | ||
| 383 | /** Run one read against a live source and shape whatever comes back. */ | |
| 384 | const read = async <T>( | |
| 385 | ctx: Ctx, | |
| 386 | run: (source: RepoSource) => Promise<Result<T, SourceError>>, | |
| 387 | shape: (value: T) => Response, | |
| 388 | ): Promise<Response> => { | |
| 389 | const source = browse(ctx); | |
| 390 | if (source instanceof Response) return source; | |
| 391 | const result = await timePhaseAsync(ctx.request, "origin", () => run(source)); | |
| 392 | return result.ok | |
| 393 | ? timePhase(ctx.request, "render", () => shape(result.value)) | |
| 394 | : sourceFailure(result.error); | |
| 395 | }; | |
| 396 | ||
| 397 | const BROWSE = "/api/v1/user/origins/:id/browse/:owner/:name"; | |
| 398 | ||
| 399 | // The landing read: what a repo page needs before it needs anything else. | |
| 400 | router.add("GET", BROWSE, async (ctx) => { | |
| 401 | const source = browse(ctx); | |
| 402 | if (source instanceof Response) return source; | |
| 403 | const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe()); | |
| 404 | if (!facts.ok) return sourceFailure(facts.error); | |
| 405 | const [branches, readme] = await timePhaseAsync(ctx.request, "origin", () => | |
| 406 | Promise.all([source.branches(), source.readme(facts.value.defaultBranch)]), | |
| 407 | ); | |
| 408 | return timePhase(ctx.request, "render", () => | |
| 409 | json({ | |
| 410 | ...whereJson(source), | |
| 411 | capabilities: source.capabilities, | |
| 412 | facts: facts.value, | |
| 413 | branches: branches.ok ? branches.value : [], | |
| 414 | readme: readme.ok ? readme.value : null, | |
| 415 | }), | |
| 416 | ); | |
| 417 | }); | |
| 418 | ||
| 419 | router.add("GET", `${BROWSE}/branches`, (ctx) => | |
| 420 | read(ctx, (s) => s.branches(), json), | |
| 421 | ); | |
| 422 | router.add("GET", `${BROWSE}/tags`, (ctx) => | |
| 423 | read(ctx, (s) => s.tags(), json), | |
| 424 | ); | |
| 425 | router.add("GET", `${BROWSE}/labels`, (ctx) => | |
| 426 | read(ctx, (s) => s.labels(), json), | |
| 427 | ); | |
| 428 | router.add("GET", `${BROWSE}/paths/:ref`, (ctx) => | |
| 429 | read(ctx, (s) => s.paths(ctx.params.ref!), json), | |
| 430 | ); | |
| 431 | ||
| 432 | const treeHandler = (ctx: Ctx): Promise<Response> => { | |
| 433 | const ref = ctx.params.ref!; | |
| 434 | const path = ctx.params.path ?? ""; | |
| 435 | return read( | |
| 436 | ctx, | |
| 437 | (s) => s.tree(ref, path), | |
| 438 | (entries) => json({ type: "dir", ref, path, entries }), | |
| 439 | ); | |
| 440 | }; | |
| 441 | router.add("GET", `${BROWSE}/tree/:ref`, treeHandler); | |
| 442 | router.add("GET", `${BROWSE}/tree/:ref/*path`, treeHandler); | |
| 443 | ||
| 444 | // Shaped exactly like the local contents endpoint, down to the 413. | |
| 445 | router.add("GET", `${BROWSE}/blob/:ref/*path`, (ctx) => { | |
| 446 | const ref = ctx.params.ref!; | |
| 447 | const path = ctx.params.path ?? ""; | |
| 448 | return read( | |
| 449 | ctx, | |
| 450 | (s) => s.blob(ref, path), | |
| 451 | (blob) => { | |
| 452 | if (blob.kind === "too_large") | |
| 453 | return json({ type: "file", tooLarge: true, size: blob.size }, 413); | |
| 454 | const binary = blob.kind === "binary"; | |
| 455 | return json({ | |
| 456 | type: "file", | |
| 457 | ref, | |
| 458 | path, | |
| 459 | size: blob.size, | |
| 460 | encoding: binary ? "base64" : "utf-8", | |
| 461 | content: binary | |
| 462 | ? Buffer.from(blob.body).toString("base64") | |
| 463 | : new TextDecoder().decode(blob.body), | |
| 464 | }); | |
| 465 | }, | |
| 466 | ); | |
| 467 | }); | |
| 468 | ||
| 469 | router.add("GET", `${BROWSE}/commits`, async (ctx) => { | |
| 470 | const source = browse(ctx); | |
| 471 | if (source instanceof Response) return source; | |
| 472 | // Only ask what the default branch is when the caller did not say — on | |
| 473 | // a live source that question is a request to another forge. | |
| 474 | let ref = ctx.url.searchParams.get("ref") ?? ""; | |
| 475 | if (ref === "") { | |
| 476 | const facts = await timePhaseAsync(ctx.request, "origin", () => source.describe()); | |
| 477 | if (!facts.ok) return sourceFailure(facts.error); | |
| 478 | ref = facts.value.defaultBranch || "HEAD"; | |
| 479 | } | |
| 480 | const cursor = ctx.url.searchParams.get("cursor") ?? undefined; | |
| 481 | const page = await timePhaseAsync(ctx.request, "origin", () => source.commits(ref, cursor)); | |
| 482 | if (!page.ok) return sourceFailure(page.error); | |
| 483 | return timePhase(ctx.request, "render", () => | |
| 484 | json({ items: page.value.items, next_cursor: page.value.next }), | |
| 485 | ); | |
| 486 | }); | |
| 487 | ||
| 488 | router.add("GET", `${BROWSE}/commits/:oid`, (ctx) => { | |
| 489 | const oid = ctx.params.oid!; | |
| 490 | return read( | |
| 491 | ctx, | |
| 492 | (s) => s.commit(oid), | |
| 493 | ({ commit, diff }) => json({ ...commit, diff }), | |
| 494 | ); | |
| 495 | }); | |
| 496 | ||
| 497 | const conversations = (kind: "issue" | "pull") => async (ctx: Ctx) => { | |
| 498 | const q = ctx.url.searchParams; | |
| 499 | const stateParam = q.get("state"); | |
| 500 | const label = q.get("label"); | |
| 501 | const author = q.get("author"); | |
| 502 | const search = q.get("q"); | |
| 503 | const cursor = q.get("cursor"); | |
| 504 | return read( | |
| 505 | ctx, | |
| 506 | (s) => | |
| 507 | s.issues({ | |
| 508 | kind, | |
| 509 | state: | |
| 510 | stateParam === "closed" || stateParam === "all" | |
| 511 | ? stateParam | |
| 512 | : "open", | |
| 513 | ...(label ? { label } : {}), | |
| 514 | ...(author ? { author } : {}), | |
| 515 | ...(search ? { query: search } : {}), | |
| 516 | ...(cursor ? { cursor } : {}), | |
| 517 | }), | |
| 518 | (page) => json({ items: page.items, next_cursor: page.next }), | |
| 519 | ); | |
| 520 | }; | |
| 521 | router.add("GET", `${BROWSE}/issues`, conversations("issue")); | |
| 522 | router.add("GET", `${BROWSE}/pulls`, conversations("pull")); | |
| 523 | ||
| 524 | const conversation = (ctx: Ctx): Promise<Response> => { | |
| 525 | const number = Number(ctx.params.number); | |
| 526 | return read( | |
| 527 | ctx, | |
| 528 | (s) => s.issue(number), | |
| 529 | (detail) => | |
| 530 | json({ | |
| 531 | ...detail.issue, | |
| 532 | body: detail.body, | |
| 533 | comments: detail.comments, | |
| 534 | pull: detail.pull, | |
| 535 | }), | |
| 536 | ); | |
| 537 | }; | |
| 538 | router.add("GET", `${BROWSE}/issues/:number`, conversation); | |
| 539 | router.add("GET", `${BROWSE}/pulls/:number`, conversation); | |
| 540 | ||
| 541 | // ── mirroring, as a job ────────────────────────────────────────────── | |
| 542 | // | |
| 543 | // 202, not 201: the repository does not exist yet. What comes back is | |
| 544 | // something to watch — poll it, or take the stream and render the stages. | |
| 545 | ||
| 546 | router.add("POST", "/api/v1/user/origins/:id/mirror", async (ctx) => { | |
| 547 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 548 | if (denied) return denied; | |
| 549 | const input = await body(ctx.request); | |
| 550 | const started = startMirrorJob( | |
| 551 | ctx.app, | |
| 552 | ctx.auth.user, | |
| 553 | ctx.params.id!, | |
| 554 | str(input.fullName), | |
| 555 | str(input.name) || undefined, | |
| 556 | ); | |
| 557 | if (!started.ok) return respondAccess(started.error, 404); | |
| 558 | return json({ job: jobJson(ctx, started.value) }, 202); | |
| 559 | }); | |
| 560 | ||
| 561 | // Fork at the origin. 202, like mirroring: the fork is asynchronous on | |
| 562 | // GitHub, and the local copy of it is a job to watch. | |
| 563 | router.add("POST", "/api/v1/user/origins/:id/fork", async (ctx) => { | |
| 564 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 565 | if (denied) return denied; | |
| 566 | const input = await body(ctx.request); | |
| 567 | const forked = await forkOrigin( | |
| 568 | ctx.app, | |
| 569 | ctx.auth.user, | |
| 570 | ctx.params.id!, | |
| 571 | str(input.fullName), | |
| 572 | ); | |
| 573 | if (!forked.ok) return contributeFailure(forked.error); | |
| 574 | return json( | |
| 575 | { | |
| 576 | remote: forked.value.remote, | |
| 577 | existed: forked.value.existed, | |
| 578 | job: | |
| 579 | forked.value.job === undefined | |
| 580 | ? null | |
| 581 | : jobJson(ctx, forked.value.job), | |
| 582 | mirroredAs: | |
| 583 | forked.value.mirror === undefined | |
| 584 | ? null | |
| 585 | : { | |
| 586 | owner: forked.value.mirror.ownerName, | |
| 587 | name: forked.value.mirror.name, | |
| 588 | }, | |
| 589 | }, | |
| 590 | 202, | |
| 591 | ); | |
| 592 | }); | |
| 593 | ||
| 594 | // Contribute to a repository we are only *browsing* — the fork flow | |
| 595 | // straight from the picker, with no mirror of the upstream first. | |
| 596 | router.add("POST", `${BROWSE}/contribute`, async (ctx) => { | |
| 597 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 598 | if (denied) return denied; | |
| 599 | const input = await body(ctx.request); | |
| 600 | const result = await contribute(ctx.app, ctx.auth.user, { | |
| 601 | originId: ctx.params.id!, | |
| 602 | fullName: `${ctx.params.owner!}/${ctx.params.name!}`, | |
| 603 | branch: str(input.branch), | |
| 604 | ...(str(input.base) === "" ? {} : { base: str(input.base) }), | |
| 605 | ...(str(input.message) === "" ? {} : { message: str(input.message) }), | |
| 606 | files: editFilesFrom(input.files), | |
| 607 | ...(typeof input.openPull === "object" && input.openPull !== null | |
| 608 | ? { | |
| 609 | openPull: { | |
| 610 | title: str((input.openPull as Record<string, unknown>).title), | |
| 611 | body: str((input.openPull as Record<string, unknown>).body), | |
| 612 | }, | |
| 613 | } | |
| 614 | : {}), | |
| 615 | }); | |
| 616 | if (!result.ok) return contributeFailure(result.error); | |
| 617 | return json(contributionJson(ctx, result.value), 201); | |
| 618 | }); | |
| 619 | ||
| 620 | router.add("GET", "/api/v1/user/origins/:id/mirror/:job", (ctx) => { | |
| 621 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 622 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 623 | if (denied) return denied; | |
| 624 | const job = timePhase(ctx.request, "job", () => | |
| 625 | mirrorJobFor(ctx.app, ctx.auth.user, ctx.params.id!, ctx.params.job!), | |
| 626 | ); | |
| 627 | if (!job.ok) return respondAccess(job.error, 404); | |
| 628 | return timePhase(ctx.request, "render", () => | |
| 629 | json({ job: jobJson(ctx, job.value) }), | |
| 630 | ); | |
| 631 | }); | |
| 632 | ||
| 633 | // The same job as a live stream, so a mirror can be watched rather than | |
| 634 | // polled. It ends itself the moment the job reaches a terminal stage. | |
| 635 | router.add("GET", "/api/v1/user/origins/:id/mirror/:job/stream", (ctx) => { | |
| 636 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 637 | if (denied) return denied; | |
| 638 | const found = mirrorJobFor( | |
| 639 | ctx.app, | |
| 640 | ctx.auth.user, | |
| 641 | ctx.params.id!, | |
| 642 | ctx.params.job!, | |
| 643 | ); | |
| 644 | if (!found.ok) return respondAccess(found.error, 404); | |
| 645 | return eventStream(ctx.app, ctx.request, (emit, close) => | |
| 646 | subscribeJob(found.value.id, (job) => { | |
| 647 | emit(`mirror.${job.stage}`, jobJson(ctx, job), job.id); | |
| 648 | if (job.stage === "done" || job.stage === "failed") close(); | |
| 649 | }), | |
| 650 | ); | |
| 651 | }); | |
| 652 | ||
| 653 | // ── pins ───────────────────────────────────────────────────────────── | |
| 654 | ||
| 655 | // The local client clones from this list, so each pin carries what a | |
| 656 | // clone needs: both URLs and where the repo came from. | |
| 657 | router.add("GET", "/api/v1/user/pins", (ctx) => { | |
| 658 | if (!ctx.auth.user) return unauthorized(); | |
| 659 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 660 | const window = keyedWindow(ctx); | |
| 661 | if (window instanceof Response) return window; | |
| 662 | const user = ctx.auth.user; | |
| 663 | const { pins, repos } = timePhase(ctx.request, "pins", () => { | |
| 664 | const pins = listPinned(ctx.app, user); | |
| 665 | // One query for every pinned repo, not one per pin. | |
| 666 | const repos = getReposByIds( | |
| 667 | ctx.app.db, | |
| 668 | pins.map((pin) => pin.repoId), | |
| 669 | ); | |
| 670 | return { pins, repos }; | |
| 671 | }); | |
| 672 | return timePhase(ctx.request, "render", () => { | |
| 673 | const items = pins.flatMap((pin) => { | |
| 674 | const repo = repos.get(pin.repoId); | |
| 675 | if (!repo) return []; | |
| 676 | const path = `${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`; | |
| 677 | return [ | |
| 678 | { | |
| 679 | repoId: repo.id, | |
| 680 | owner: repo.ownerName, | |
| 681 | name: repo.name, | |
| 682 | defaultBranch: repo.defaultBranch, | |
| 683 | cloneUrl: `${ctx.url.origin}/${path}.git`, | |
| 684 | sshUrl: | |
| 685 | sshCloneUrl(ctx.app, repo.ownerName, repo.name) ?? null, | |
| 686 | origin: originFacts(repo), | |
| 687 | pinnedAt: pin.createdAt, | |
| 688 | }, | |
| 689 | ]; | |
| 690 | }); | |
| 691 | return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window)); | |
| 692 | }); | |
| 693 | }); | |
| 694 | ||
| 695 | router.add("PUT", "/api/v1/user/pins/:owner/:name", (ctx) => { | |
| 696 | const denied = requireScope(ctx, "rw"); | |
| 697 | if (denied) return denied; | |
| 698 | const repo = loadRepo(ctx); | |
| 699 | if (!repo) return notFound(); | |
| 700 | const pinned = pinRepo(ctx.app, ctx.auth.user, repo); | |
| 701 | if (!pinned.ok) return respondAccess(pinned.error, 404); | |
| 702 | return json({ pinned: true }); | |
| 703 | }); | |
| 704 | ||
| 705 | // What is on the caller: open issues and pulls assigned to them, anywhere. | |
| 706 | router.add("GET", "/api/v1/user/assigned", (ctx) => { | |
| 707 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 708 | const denied = requireScope(ctx, "ro"); | |
| 709 | if (denied) return denied; | |
| 710 | const user = ctx.auth.user!; | |
| 711 | const items = timePhase(ctx.request, "assigned", () => { | |
| 712 | const issues = listAssignedTo(ctx.app.db, user.id); | |
| 713 | const repos = getReposByIds( | |
| 714 | ctx.app.db, | |
| 715 | issues.map((i) => i.repoId), | |
| 716 | ); | |
| 717 | const out = []; | |
| 718 | for (const issue of issues) { | |
| 719 | const repo = repos.get(issue.repoId); | |
| 720 | if (!repo || !canRead(ctx.app.db, repo, user)) continue; | |
| 721 | if (!mayView(ctx.app.db, repo, issue, user)) continue; | |
| 722 | out.push({ | |
| 723 | ...issue, | |
| 724 | repo: { owner: repo.ownerName, name: repo.name }, | |
| 725 | }); | |
| 726 | } | |
| 727 | return out; | |
| 728 | }); | |
| 729 | return timePhase(ctx.request, "render", () => json({ items, next_cursor: null })); | |
| 730 | }); | |
| 731 | ||
| 732 | router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => { | |
| 733 | const denied = requireScope(ctx, "rw"); | |
| 734 | if (denied) return denied; | |
| 735 | const repo = loadRepo(ctx); | |
| 736 | if (!repo) return notFound(); | |
| 737 | const removed = unpinRepo(ctx.app, ctx.auth.user, repo); | |
| 738 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 739 | return new Response(null, { status: 204 }); | |
| 740 | }); | |
| 741 | } |
new above
offline