Public: anyone can read this; the crew can change it.
Connecting a friend's forge is obvious and works with forges on an older release #9390
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9388-connecting-a-friend-s-forge-is-obvious-a → main 3 commits fork 1 thread · 1 unresolved
16 files
changed +699
−96
docs/features/forge-origin-older.md added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | # Connecting a friend's forge, whatever release it runs (#9388) | |
| 2 | ||
| 3 | `/settings/origins` leads with **Another forge**: its address and a token made on it (`<their forge>/settings/tokens`, read is enough; private repositories come with it), or **Or sign in there** for one click. | |
| 4 | ||
| 5 | `/bring` with a link to a forge you have not connected shows **Connect that forge first** on the same page: paste a token (or sign in there), and the app starts coming across at once. The link is kept through the sign-in (`then=bring&link=…`, the link checked as a short http(s) URL, the return always a path on this forge). | |
| 6 | ||
| 7 | ## Older forges | |
| 8 | ||
| 9 | A forge from before `/api/v1/version` (0.5.x) is recognised by two answers no other software gives together: `/api/v1/health` (`ok`, a dotted `version`, a `commit`) and `/api/v1/here` (`here`, `actions`). It is stored like any forge (`software = forge`). Without `/api/v1/user/repos`, its repositories are listed from `GET /api/v1/repos` with the token, keeping the account's own, private ones included (`src/origins/providers/forge.ts`, at most 10 pages per call). Test: `test/forge-origin-older.test.ts` runs a fixture of such a forge's answers. | |
| 10 | ||
| 11 | Not checked: the live forge-apps.benjivers.com — the build sandbox reaches only this forge, so the old-forge path is proven against the fixture, not against that host. | |
| 12 | ||
| 13 | The fixture is written from this repository's own routes, not captured from a real 0.5.x forge, so what a 0.5.x forge actually answers is unverified. There is no "needs a newer release" error either: a forge that matches neither fingerprint is refused as unrecognised. | |
| 14 | ||
| 15 | ## Tokens and plain http | |
| 16 | ||
| 17 | A token is sent to the forge named by the link, so `http:` links are refused for it (page and terminal) unless `FORGE_DEV=1`; the one-click sign-in still works. `connectSite` in `src/origins/bring.ts` decides. | |
| 18 | ||
| 19 | ## In the terminal | |
| 20 | ||
| 21 | Bring an app (palette) with a link to an unconnected forge asks for its token, masked, connects, and carries on with the same link (`src/tui/screens/bring.ts`). Connected origins (`c`) takes an address and a token as before. Both are for a person's own account, never an agent's. | |
| 22 | ||
| 23 | ## One accent | |
| 24 | ||
| 25 | Another forge's Connect is the only primary button on the origins page; the other paths' buttons are plain. On `/bring`, the link form is plain while the connect card is showing, and external links use the shared button component. |
src/http/server.ts +2 −1
| @@ -423,7 +423,7 @@ import { | ||
| 423 | 423 | browseRaw, |
| 424 | 424 | browseTree, |
| 425 | 425 | } from "../web/pages/browse.ts"; |
| 426 | import { bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts"; | |
| 426 | import { bringConnect, bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts"; | |
| 427 | 427 | import { pushThrough } from "../origins/push-through.ts"; |
| 428 | 428 | import { CAME_FROM_ELSEWHERE, forbiddenPage, notFoundPage, SENT_FROM_ELSEWHERE } from "../web/chrome.ts"; |
| 429 | 429 | import { targetForShortCode } from "../origins/oauth.ts"; |
| @@ -784,6 +784,7 @@ export function buildRouter(): Router<Handler> { | ||
| 784 | 784 | router.add("GET", "/new", newRepoPage); |
| 785 | 785 | router.add("GET", "/bring", bringPage); |
| 786 | 786 | router.add("POST", "/bring", bringSubmit); |
| 787 | router.add("POST", "/bring/connect", bringConnect); | |
| 787 | 788 | router.add("GET", "/bring/:id/:job", bringJobPage); |
| 788 | 789 | router.add("POST", "/bring/:id/:job/run", bringRun); |
| 789 | 790 | router.add("POST", "/bring/:id/:job/env", bringEnvSubmit); |
src/origins/bring.ts +21
| @@ -69,3 +69,24 @@ export function needsOf(example: string): string[] { | ||
| 69 | 69 | } |
| 70 | 70 | return names; |
| 71 | 71 | } |
| 72 | ||
| 73 | /** Words for a token that would travel in clear. */ | |
| 74 | export const PLAIN_HTTP_WORDS = | |
| 75 | "That link is plain http, and a token sent there travels in clear. Use the https link."; | |
| 76 | ||
| 77 | /** | |
| 78 | * The address to connect for a link: `https:` unless the link itself says | |
| 79 | * `http:`, and then only on a development instance (`FORGE_DEV=1`), because a | |
| 80 | * token is sent to it. | |
| 81 | */ | |
| 82 | export function connectSite( | |
| 83 | link: string, | |
| 84 | host: string, | |
| 85 | env: Record<string, string | undefined> = process.env, | |
| 86 | ): { ok: true; value: string } | { ok: false; error: "plain_http" } { | |
| 87 | if (/^http:\/\//i.test(link.trim())) { | |
| 88 | if (env.FORGE_DEV !== "1") return { ok: false, error: "plain_http" }; | |
| 89 | return { ok: true, value: `http://${host}` }; | |
| 90 | } | |
| 91 | return { ok: true, value: `https://${host}` }; | |
| 92 | } |
src/origins/discover.ts +41
| @@ -227,6 +227,10 @@ export async function discover( | ||
| 227 | 227 | const version = await viaVersionEndpoint(web, probe); |
| 228 | 228 | if (version) return ok(version); |
| 229 | 229 | |
| 230 | // A forge from before `/api/v1/version` still answers health and here. | |
| 231 | const older = await viaOlderForge(web, probe); | |
| 232 | if (older) return ok(older); | |
| 233 | ||
| 230 | 234 | return err(probe.refused ? "private_host" : "unreachable"); |
| 231 | 235 | } |
| 232 | 236 | |
| @@ -258,6 +262,43 @@ async function viaVersionEndpoint( | ||
| 258 | 262 | }; |
| 259 | 263 | } |
| 260 | 264 | |
| 265 | /** | |
| 266 | * A forge too old to answer `/api/v1/version` (0.5.x). It is known by two | |
| 267 | * answers no other software gives together: `/api/v1/health` with a boolean | |
| 268 | * `ok`, a dotted `version` and a `commit`; and `/api/v1/here` with the | |
| 269 | * manifest's `here` and `actions`. Its repositories are then listed through | |
| 270 | * `GET /api/v1/repos` (`ForgeProvider.listRepos`). | |
| 271 | */ | |
| 272 | async function viaOlderForge( | |
| 273 | web: string, | |
| 274 | probe: Probe, | |
| 275 | ): Promise<Discovered | undefined> { | |
| 276 | const health = await getJson(`${web}/api/v1/health`, web, probe); | |
| 277 | if (!health.ok) return undefined; | |
| 278 | const version = asString(field(health.value, "version")); | |
| 279 | if ( | |
| 280 | typeof field(health.value, "ok") !== "boolean" || | |
| 281 | !/^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40}$/.test(version) || | |
| 282 | asString(field(health.value, "commit")) === "" | |
| 283 | ) | |
| 284 | return undefined; | |
| 285 | const here = await getJson(`${web}/api/v1/here`, web, probe); | |
| 286 | if (!here.ok) return undefined; | |
| 287 | if ( | |
| 288 | typeof field(here.value, "here") !== "object" || | |
| 289 | field(here.value, "here") === null || | |
| 290 | !Array.isArray(field(here.value, "actions")) | |
| 291 | ) | |
| 292 | return undefined; | |
| 293 | return { | |
| 294 | kind: "gitea", | |
| 295 | apiUrl: `${web}/api/v1`, | |
| 296 | webUrl: web, | |
| 297 | via: "health", | |
| 298 | software: `forge ${version}`, | |
| 299 | }; | |
| 300 | } | |
| 301 | ||
| 261 | 302 | async function viaNodeInfo( |
| 262 | 303 | web: string, |
| 263 | 304 | probe: Probe, |
src/origins/providers/forge.ts +51 −13
| @@ -28,6 +28,7 @@ import { forgeCursor, toForgeComment, toForgeWork } from "./forge-work.ts"; | ||
| 28 | 28 | import { asArray, asId, asString, asTime, dig, fail, field, seg, splitFullName, succeed } from "./http.ts"; |
| 29 | 29 | |
| 30 | 30 | const MAX_COMMENT_PAGES = 1000; |
| 31 | const OLDER_PAGES = 10; | |
| 31 | 32 | const NONE = { ok: true, value: [] } as const; |
| 32 | 33 | |
| 33 | 34 | export class ForgeProvider extends ForgejoProvider { |
| @@ -51,6 +52,8 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 51 | 52 | const page = Math.max(1, Number(cursor) || 1); |
| 52 | 53 | const limit = await this.pageSize(); |
| 53 | 54 | const result = await this.api.call({ path: "/user/repos", query: { limit, page } }); |
| 55 | // A forge from before `/user/repos` (0.5.x) has no such route. | |
| 56 | if (!result.ok && result.error.error === "not_found") return this.listReposOlder(cursor, limit); | |
| 54 | 57 | if (!result.ok) return result; |
| 55 | 58 | const rows = asArray(result.value.data); |
| 56 | 59 | return succeed({ |
| @@ -59,19 +62,43 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 59 | 62 | }); |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | /** One repository, from a forge's own JSON (`ownerName`, `visibility`). */ | |
| 63 | override async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> { | |
| 64 | const named = splitFullName(fullName); | |
| 65 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); | |
| 66 | const result = await this.api.call({ | |
| 67 | path: `/repos/${seg(named.owner)}/${seg(named.name)}`, | |
| 68 | }); | |
| 69 | if (!result.ok) return result; | |
| 70 | const raw = result.value.data; | |
| 71 | const owner = asString(field(raw, "ownerName")) || named.owner; | |
| 72 | const name = asString(field(raw, "name")) || named.name; | |
| 65 | /** | |
| 66 | * An old forge's list: `GET /repos` pages by cursor and shows everything the | |
| 67 | * token may see, so the account's own repositories are picked out of it. A | |
| 68 | * cursor here is the forge's `next_cursor`, not a page number. At most | |
| 69 | * `OLDER_PAGES` pages are read for one call, so a huge forge costs a bounded | |
| 70 | * walk; the rest is one "next" away. | |
| 71 | */ | |
| 72 | private async listReposOlder( | |
| 73 | cursor: string | undefined, | |
| 74 | limit: number, | |
| 75 | ): Promise<ProviderResult<Page<RemoteRepo>>> { | |
| 76 | const me = await this.whoami(); | |
| 77 | if (!me.ok) return me; | |
| 78 | const own: RemoteRepo[] = []; | |
| 79 | let after = cursor !== undefined && /^[A-Za-z0-9_-]{1,64}$/.test(cursor) ? cursor : undefined; | |
| 80 | for (let walked = 0; walked < OLDER_PAGES; walked++) { | |
| 81 | const result = await this.api.call({ | |
| 82 | path: "/repos", | |
| 83 | query: { limit: 100, ...(after === undefined ? {} : { cursor: after }) }, | |
| 84 | }); | |
| 85 | if (!result.ok) return result; | |
| 86 | for (const raw of asArray(field(result.value.data, "items"))) | |
| 87 | if (asString(field(raw, "ownerName")).toLowerCase() === me.value.login.toLowerCase()) | |
| 88 | own.push(this.repoFrom(raw, undefined)); | |
| 89 | const next = field(result.value.data, "next_cursor"); | |
| 90 | after = typeof next === "string" || typeof next === "number" ? String(next) : undefined; | |
| 91 | if (after === undefined || own.length >= limit) break; | |
| 92 | } | |
| 93 | return succeed({ items: own, next: after ?? null }); | |
| 94 | } | |
| 95 | ||
| 96 | /** A repository from a forge's own JSON (`ownerName`, `visibility`). */ | |
| 97 | private repoFrom(raw: unknown, named: { owner: string; name: string } | undefined): RemoteRepo { | |
| 98 | const owner = asString(field(raw, "ownerName")) || named?.owner || ""; | |
| 99 | const name = asString(field(raw, "name")) || named?.name || ""; | |
| 73 | 100 | const web = `${this.webUrl}/${owner}/${name}`; |
| 74 | return succeed( | |
| 101 | return ( | |
| 75 | 102 | toRepo({ |
| 76 | 103 | id: field(raw, "id"), |
| 77 | 104 | full_name: `${owner}/${name}`, |
| @@ -87,10 +114,21 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 87 | 114 | // "cannot push": the direct way is tried, and its refusal falls |
| 88 | 115 | // back to the account's own fork. |
| 89 | 116 | permissions: { push: true, admin: false }, |
| 90 | }), | |
| 117 | }) | |
| 91 | 118 | ); |
| 92 | 119 | } |
| 93 | 120 | |
| 121 | /** One repository, from a forge's own JSON. */ | |
| 122 | override async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> { | |
| 123 | const named = splitFullName(fullName); | |
| 124 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); | |
| 125 | const result = await this.api.call({ | |
| 126 | path: `/repos/${seg(named.owner)}/${seg(named.name)}`, | |
| 127 | }); | |
| 128 | if (!result.ok) return result; | |
| 129 | return succeed(this.repoFrom(result.value.data, named)); | |
| 130 | } | |
| 131 | ||
| 94 | 132 | override listLabels(): Promise<ProviderResult<readonly RemoteLabel[]>> { |
| 95 | 133 | return Promise.resolve(NONE); |
| 96 | 134 | } |
src/origins/types.ts +1 −1
| @@ -575,6 +575,6 @@ export interface Discovered { | ||
| 575 | 575 | readonly apiUrl: string; |
| 576 | 576 | readonly webUrl: string; |
| 577 | 577 | /** How we knew: the evidence to show the user. */ |
| 578 | readonly via: "known" | "nodeinfo" | "version-endpoint"; | |
| 578 | readonly via: "known" | "nodeinfo" | "version-endpoint" | "health"; | |
| 579 | 579 | readonly software: string; |
| 580 | 580 | } |
src/tui/screens/bring.ts +53 −15
| @@ -6,10 +6,13 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { bringLanded, setBroughtEnv, startBring, type BringError, type Brought } from "../../actions/bring.ts"; |
| 9 | import { connectOrigin, connectRefusal } from "../../actions/origins.ts"; | |
| 10 | import { connectWords } from "../../actions/origin_words.ts"; | |
| 11 | import { connectSite, PLAIN_HTTP_WORDS } from "../../origins/bring.ts"; | |
| 9 | 12 | import type { MirrorJob } from "../../origins/jobs.ts"; |
| 10 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 14 | import type { Session } from "../session.ts"; |
| 12 | import { promptScreen, runAsync } from "./common.ts"; | |
| 15 | import { later, promptScreen, runAsync } from "./common.ts"; | |
| 13 | 16 | import { showMirrorJob } from "./mirror.ts"; |
| 14 | 17 | |
| 15 | 18 | /** The words for a refusal, as the page says them. */ |
| @@ -60,24 +63,59 @@ function land(f: Session, ctx: Ctx, parent: Screen, job: MirrorJob): void { | ||
| 60 | 63 | }); |
| 61 | 64 | } |
| 62 | 65 | |
| 66 | /** Start the bring for a link and show its job; the answer in words when it cannot start. */ | |
| 67 | function begin(f: Session, ctx: Ctx, parent: Screen, link: string): string | Promise<void> | undefined { | |
| 68 | const started = startBring(f.app, f.user, link); | |
| 69 | if (!started.ok) return started.error.kind === "connect" ? connectFirst(f, ctx, parent, link, started.error.host) : bringWords(started.error); | |
| 70 | const { job, originId } = started.value; | |
| 71 | return Promise.resolve().then(() => | |
| 72 | showMirrorJob(f, ctx, job, { | |
| 73 | parent, | |
| 74 | originId, | |
| 75 | fullName: job.fullName, | |
| 76 | onLanded: () => land(f, ctx, parent, job), | |
| 77 | }), | |
| 78 | ); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The link names a forge no account here is connected to: ask for a token made | |
| 83 | * on it (masked), connect, and carry on with the same link. Plain http is | |
| 84 | * refused, as the page does, unless this is a development forge. | |
| 85 | */ | |
| 86 | function connectFirst(f: Session, ctx: Ctx, parent: Screen, link: string, host: string): string | Promise<void> { | |
| 87 | const site = connectSite(link, host); | |
| 88 | if (!site.ok) return PLAIN_HTTP_WORDS; | |
| 89 | return later(ctx, () => | |
| 90 | promptScreen(ctx, parent, { | |
| 91 | title: `Connect ${host} first`, | |
| 92 | label: `Token for ${host}`, | |
| 93 | hint: `Made at ${site.value}/settings/tokens; read is enough. Kept encrypted here.`, | |
| 94 | mask: true, | |
| 95 | onSubmit: (token) => { | |
| 96 | if (token.trim() === "") return "Paste the token."; | |
| 97 | ctx.toast(`Connecting ${host}…`); | |
| 98 | return connectOrigin(f.app, f.user!, { host: site.value, token }).then((r) => { | |
| 99 | if (!r.ok) { | |
| 100 | const refused = connectRefusal(r.error); | |
| 101 | ctx.toast(refused.message ?? connectWords(refused.code), "danger"); | |
| 102 | return; | |
| 103 | } | |
| 104 | ctx.toast(`Connected ${r.value.login} at ${host}.`, "success"); | |
| 105 | const next = begin(f, ctx, parent, link); | |
| 106 | if (typeof next === "string") ctx.toast(next, "danger"); | |
| 107 | }); | |
| 108 | }, | |
| 109 | }), | |
| 110 | ); | |
| 111 | } | |
| 112 | ||
| 63 | 113 | export function bringScreen(f: Session, ctx: Ctx, parent: Screen): Screen { |
| 64 | 114 | return promptScreen(ctx, parent, { |
| 65 | 115 | title: "Bring an app", |
| 66 | 116 | label: "Link to the app", |
| 67 | 117 | placeholder: "https://codeberg.org/ana/notes", |
| 68 | hint: "On a forge you have connected. Its data and secrets stay behind.", | |
| 69 | onSubmit: (link) => { | |
| 70 | const started = startBring(f.app, f.user, link); | |
| 71 | if (!started.ok) return bringWords(started.error); | |
| 72 | const { job, originId } = started.value; | |
| 73 | return Promise.resolve().then(() => | |
| 74 | showMirrorJob(f, ctx, job, { | |
| 75 | parent, | |
| 76 | originId, | |
| 77 | fullName: job.fullName, | |
| 78 | onLanded: () => land(f, ctx, parent, job), | |
| 79 | }), | |
| 80 | ); | |
| 81 | }, | |
| 118 | hint: "On another forge. If you have not connected it, you are asked for a token. Its data and secrets stay behind.", | |
| 119 | onSubmit: (link) => begin(f, ctx, parent, link), | |
| 82 | 120 | }); |
| 83 | 121 | } |
src/web/pages/bring.ts +81 −14
| @@ -9,21 +9,68 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { bringLanded, bringLook, setBroughtEnv, startBring, type BringError } from "../../actions/bring.ts"; |
| 11 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 12 | import { mirrorJobFor } from "../../actions/origins.ts"; | |
| 12 | import { connectOrigin, connectRefusal, mirrorJobFor } from "../../actions/origins.ts"; | |
| 13 | import { connectWords } from "../../actions/origin_words.ts"; | |
| 14 | import { connectSite, parseBringLink, PLAIN_HTTP_WORDS } from "../../origins/bring.ts"; | |
| 13 | 15 | import { notFoundPage } from "../chrome.ts"; |
| 14 | 16 | import { readForm } from "../form.ts"; |
| 15 | 17 | import type { Flash } from "../flash.ts"; |
| 16 | import { html } from "../html.ts"; | |
| 18 | import { html, type Html } from "../html.ts"; | |
| 17 | 19 | import { page, redirect } from "../layout.ts"; |
| 18 | 20 | import { mirrorJs } from "../client.ts"; |
| 19 | 21 | import { btn, card, field, icon } from "../ui.ts"; |
| 20 | 22 | import type { Ctx } from "../../http/context.ts"; |
| 21 | import { accountOnly } from "./origins.ts"; | |
| 23 | import { accountOnly, hostLabel } from "./origins.ts"; | |
| 22 | 24 | import { stageList } from "./browse.ts"; |
| 23 | 25 | |
| 24 | 26 | const NOT_COPIED = ["data", "variables", "secrets"]; |
| 25 | 27 | |
| 26 | function form(ctx: Ctx, value: string, problem?: Flash, status = 200): Response { | |
| 28 | /** The card's address: `http:` only when the link says so (the card then refuses a token unless this is a development forge). */ | |
| 29 | const cardSite = (host: string, link: string): string => `${/^http:\/\//i.test(link.trim()) ? "http:" : "https:"}//${host}`; | |
| 30 | ||
| 31 | /** | |
| 32 | * The link names a forge no account here is connected to: connect it in | |
| 33 | * place. The link is kept in the form, the token is asked for where the | |
| 34 | * friend's forge makes one, and the one click works too when it can. | |
| 35 | */ | |
| 36 | function connectCard(host: string, link: string, error?: string): Html { | |
| 37 | const site = cardSite(host, link); | |
| 38 | return card( | |
| 39 | html`<p> | |
| 40 | This app is on <strong>${hostLabel(site)}</strong>, and you have not | |
| 41 | connected your account there yet. Do it here: your link is kept. | |
| 42 | </p> | |
| 43 | <form method="post" action="/bring/connect" class="stack" id="bring-connect"> | |
| 44 | <input type="hidden" name="link" value="${link}" /> | |
| 45 | <p class="small"> | |
| 46 | Make a token on their forge: | |
| 47 | ${btn(`${hostLabel(site)}/settings/tokens`, { href: `${site}/settings/tokens`, external: true, variant: "link" })} | |
| 48 | </p> | |
| 49 | ${field({ | |
| 50 | label: `Your token on ${hostLabel(site)}`, | |
| 51 | name: "token", | |
| 52 | type: "password", | |
| 53 | required: true, | |
| 54 | mono: true, | |
| 55 | autocomplete: "off", | |
| 56 | spellcheck: false, | |
| 57 | hint: "Read is enough to bring an app; private repositories come with it. It is never shown again.", | |
| 58 | ...(error === undefined ? {} : { error }), | |
| 59 | })} | |
| 60 | <div class="form-actions">${btn("Connect and bring it", { variant: "primary", icon: "download" })}</div> | |
| 61 | </form> | |
| 62 | <form method="post" action="/settings/origins/oauth" class="form-actions"> | |
| 63 | <input type="hidden" name="host" value="${site}" /> | |
| 64 | <input type="hidden" name="then" value="bring" /> | |
| 65 | <input type="hidden" name="link" value="${link}" /> | |
| 66 | ${btn("Sign in there instead", { icon: "external" })} | |
| 67 | <span class="muted small">One click, no token to copy.</span> | |
| 68 | </form>`, | |
| 69 | { id: "bring-connect-card", head: html`${icon("globe")}<h2>Connect ${hostLabel(site)} first</h2>` }, | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | function form(ctx: Ctx, value: string, problem?: Flash, status = 200, connect?: Html): Response { | |
| 27 | 74 | return page( |
| 28 | 75 | { |
| 29 | 76 | ctx, |
| @@ -50,10 +97,11 @@ function form(ctx: Ctx, value: string, problem?: Flash, status = 200): Response | ||
| 50 | 97 | placeholder: "https://codeberg.org/ana/notes", |
| 51 | 98 | spellcheck: false, |
| 52 | 99 | })} |
| 53 | <div class="bar">${btn("Bring it here", { variant: "primary", icon: "download" })}</div> | |
| 100 | <div class="bar">${btn("Bring it here", { ...(connect === undefined ? { variant: "primary" as const } : {}), icon: "download" })}</div> | |
| 54 | 101 | </form>`, |
| 55 | 102 | { head: html`${icon("download")}<h2>Bring it here</h2>` }, |
| 56 | 103 | )} |
| 104 | ${connect ?? null} | |
| 57 | 105 | </div>`, |
| 58 | 106 | }, |
| 59 | 107 | status, |
| @@ -69,15 +117,7 @@ const danger = (message: string, link?: Flash["link"]): Flash => ({ | ||
| 69 | 117 | function refusal(ctx: Ctx, link: string, e: BringError): Response { |
| 70 | 118 | switch (e.kind) { |
| 71 | 119 | case "connect": |
| 72 | return form( | |
| 73 | ctx, | |
| 74 | link, | |
| 75 | danger(`Sign in to ${e.host} first, then paste the link again.`, { | |
| 76 | href: "/settings/origins", | |
| 77 | label: "connect your account there", | |
| 78 | }), | |
| 79 | 400, | |
| 80 | ); | |
| 120 | return form(ctx, link, undefined, 200, connectCard(e.host, link)); | |
| 81 | 121 | case "invalid": |
| 82 | 122 | return form(ctx, link, danger(e.message), 400); |
| 83 | 123 | case "unverified": |
| @@ -111,6 +151,33 @@ export async function bringSubmit(ctx: Ctx): Promise<Response> { | ||
| 111 | 151 | return redirect(`/bring/${started.value.originId}/${started.value.job.id}`); |
| 112 | 152 | } |
| 113 | 153 | |
| 154 | /** POST /bring/connect: a token for the forge the link names, then straight on with the bring. */ | |
| 155 | export async function bringConnect(ctx: Ctx): Promise<Response> { | |
| 156 | const denied = accountOnly(ctx); | |
| 157 | if (denied) return denied; | |
| 158 | const data = await readForm(ctx, "admin"); | |
| 159 | if (data instanceof Response) return data; | |
| 160 | const raw = data.get("link"); | |
| 161 | const link = (typeof raw === "string" ? raw : "").slice(0, 300); | |
| 162 | const token = data.get("token"); | |
| 163 | const parsed = parseBringLink(link); | |
| 164 | if (!parsed.ok) return form(ctx, link, danger("That is not a link to a repository (https://host/owner/name)."), 400); | |
| 165 | const site = connectSite(link, parsed.value.host); | |
| 166 | if (!site.ok) | |
| 167 | return form(ctx, link, undefined, 400, connectCard(parsed.value.host, link, PLAIN_HTTP_WORDS)); | |
| 168 | const connected = await connectOrigin(ctx.app, ctx.auth.user, { | |
| 169 | host: site.value, | |
| 170 | token: typeof token === "string" ? token : "", | |
| 171 | }); | |
| 172 | if (!connected.ok) { | |
| 173 | const refused = connectRefusal(connected.error); | |
| 174 | return form(ctx, link, undefined, 422, connectCard(parsed.value.host, link, refused.message ?? connectWords(refused.code))); | |
| 175 | } | |
| 176 | const started = startBring(ctx.app, ctx.auth.user, link); | |
| 177 | if (!started.ok) return refusal(ctx, link, started.error); | |
| 178 | return redirect(`/bring/${started.value.originId}/${started.value.job.id}`); | |
| 179 | } | |
| 180 | ||
| 114 | 181 | /** GET /bring/:id/:job */ |
| 115 | 182 | export async function bringJobPage(ctx: Ctx): Promise<Response> { |
| 116 | 183 | const denied = accountOnly(ctx); |
src/web/pages/origin-flow.ts +15 −11
| @@ -23,13 +23,13 @@ import { | ||
| 23 | 23 | hostLabel, |
| 24 | 24 | notFoundNote, |
| 25 | 25 | shell, |
| 26 | linkOf, | |
| 26 | 27 | thenOf, |
| 28 | thenQuery, | |
| 29 | thenTarget, | |
| 27 | 30 | type Then, |
| 28 | 31 | } from "./origins.ts"; |
| 29 | 32 | |
| 30 | const thenQuery = (then: Then): string => | |
| 31 | then === undefined ? "" : "then=migrate"; | |
| 32 | ||
| 33 | 33 | /** |
| 34 | 34 | * GET /settings/origins/flows/:id — a connect in progress. |
| 35 | 35 | * |
| @@ -44,9 +44,9 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 44 | 44 | const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? ""); |
| 45 | 45 | if (!flow) return shell(ctx, notFoundNote(), 404); |
| 46 | 46 | const then = thenOf(ctx.url.searchParams.get("then")); |
| 47 | const link = linkOf(ctx.url.searchParams.get("link")); | |
| 47 | 48 | |
| 48 | if (flow.status === "done") | |
| 49 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 49 | if (flow.status === "done") return redirect(thenTarget(then, link)); | |
| 50 | 50 | if (flow.status === "failed") |
| 51 | 51 | return redirect( |
| 52 | 52 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 300))}`, |
| @@ -54,9 +54,9 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 54 | 54 | |
| 55 | 55 | // A pending flow past its time leads nowhere: not to a consent screen |
| 56 | 56 | // for a state that will be refused, nor to a code that no longer works. |
| 57 | if (flowExpired(flow)) return expiredFlow(ctx, flow, then); | |
| 57 | if (flowExpired(flow)) return expiredFlow(ctx, flow, then, link); | |
| 58 | 58 | |
| 59 | const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`; | |
| 59 | const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then, link)}`}`; | |
| 60 | 60 | const authorize = flow.flow === "code" ? authorizeFor(ctx, flow) : undefined; |
| 61 | 61 | const base = forgeBaseUrl(ctx.url, ctx.app.config.host); |
| 62 | 62 | const shortLink = |
| @@ -248,6 +248,7 @@ function expiredFlow( | ||
| 248 | 248 | ctx: Ctx, |
| 249 | 249 | flow: { kind: string; webUrl: string }, |
| 250 | 250 | then: Then, |
| 251 | link: string | undefined, | |
| 251 | 252 | ): Response { |
| 252 | 253 | return page({ |
| 253 | 254 | ctx, |
| @@ -265,7 +266,10 @@ function expiredFlow( | ||
| 265 | 266 | : null} |
| 266 | 267 | ${then === undefined |
| 267 | 268 | ? null |
| 268 | : html`<input type="hidden" name="then" value="migrate" />`} | |
| 269 | : html`<input type="hidden" name="then" value="${then === "/bring" ? "bring" : "migrate"}" />`} | |
| 270 | ${link === undefined | |
| 271 | ? null | |
| 272 | : html`<input type="hidden" name="link" value="${link}" />`} | |
| 269 | 273 | ${btn(`Connect ${hostLabel(flow.webUrl)}`, { |
| 270 | 274 | variant: "primary", |
| 271 | 275 | icon: "external", |
| @@ -311,14 +315,14 @@ export async function originFlowPoll(ctx: Ctx): Promise<Response> { | ||
| 311 | 315 | } |
| 312 | 316 | const { status, detail } = polled.value; |
| 313 | 317 | const then = thenOf(ctx.url.searchParams.get("then")); |
| 318 | const link = linkOf(ctx.url.searchParams.get("link")); | |
| 314 | 319 | if (wantsJson) return Response.json({ status, detail }); |
| 315 | if (status === "done") | |
| 316 | return redirect(`${then ?? "/settings/origins"}?connected=1`); | |
| 320 | if (status === "done") return redirect(thenTarget(then, link)); | |
| 317 | 321 | if (status === "failed") |
| 318 | 322 | return redirect( |
| 319 | 323 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 300))}`, |
| 320 | 324 | ); |
| 321 | 325 | return redirect( |
| 322 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`, | |
| 326 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then, link)}`}`, | |
| 323 | 327 | ); |
| 324 | 328 | } |
src/web/pages/origins.ts +94 −16
| @@ -485,7 +485,7 @@ function githubSection(configured: boolean, callback: string): Html { | ||
| 485 | 485 | </p> |
| 486 | 486 | <form method="post" action="/settings/origins/oauth"> |
| 487 | 487 | <input type="hidden" name="kind" value="github" /> |
| 488 | ${btn("Connect GitHub", { variant: "primary", icon: "github" })} | |
| 488 | ${btn("Connect GitHub", { icon: "github" })} | |
| 489 | 489 | </form>`; |
| 490 | 490 | } |
| 491 | 491 | |
| @@ -522,7 +522,7 @@ function forgejoSection( | ||
| 522 | 522 | id <span class="mono">${registered.clientId}</span>). |
| 523 | 523 | </p> |
| 524 | 524 | <div class="bar"> |
| 525 | ${btn("Authorize", { variant: "primary", icon: "external" })} | |
| 525 | ${btn("Authorize", { icon: "external" })} | |
| 526 | 526 | ${btn("Forget this app", { |
| 527 | 527 | size: "sm", |
| 528 | 528 | variant: "ghost", |
| @@ -590,7 +590,7 @@ function forgejoSection( | ||
| 590 | 590 | hint: "Optional. A public client is fine; a secret only buys revoke-on-disconnect.", |
| 591 | 591 | })} |
| 592 | 592 | <div class="form-actions"> |
| 593 | ${btn("Register application", { variant: "primary" })} | |
| 593 | ${btn("Register application")} | |
| 594 | 594 | </div> |
| 595 | 595 | </form> |
| 596 | 596 | </div>` |
| @@ -667,10 +667,62 @@ function tokenSection(values: ConnectValues): Html { | ||
| 667 | 667 | hint: "It is never displayed after this.", |
| 668 | 668 | })} |
| 669 | 669 | ${tokenNeeds(values.tokenHost)} |
| 670 | <div class="form-actions">${btn("Connect", { variant: "primary" })}</div> | |
| 670 | <div class="form-actions">${btn("Connect")}</div> | |
| 671 | 671 | </form>`; |
| 672 | 672 | } |
| 673 | 673 | |
| 674 | /** | |
| 675 | * The path a friend's forge needs, first on the page: its address and a token | |
| 676 | * made on it, or the one click. The line says where that token is made. | |
| 677 | */ | |
| 678 | function forgeSection(ctx: Ctx, values: ConnectValues): Html { | |
| 679 | const site = | |
| 680 | values.tokenHost === "" | |
| 681 | ? null | |
| 682 | : URL.parse(values.tokenHost.includes("://") ? values.tokenHost : `https://${values.tokenHost}`); | |
| 683 | const tokens = | |
| 684 | site === null || (site.protocol !== "https:" && site.protocol !== "http:") | |
| 685 | ? html`<span class="mono">/settings/tokens</span> on their forge` | |
| 686 | : btn(`${site.host}/settings/tokens`, { href: `${site.origin}/settings/tokens`, external: true, variant: "link" }); | |
| 687 | return html`<p class="muted small"> | |
| 688 | You have an account on a friend's forge and want their apps here. Type | |
| 689 | their address and a token from them; the token is checked before it is | |
| 690 | stored, encrypted at rest, and never shown again. Their forge may run an | |
| 691 | older release than this one: that is fine. | |
| 692 | </p> | |
| 693 | <form method="post" action="/settings/origins/token"> | |
| 694 | ${uiField({ | |
| 695 | label: "Their forge's address", | |
| 696 | name: "host", | |
| 697 | id: "f-forge-host", | |
| 698 | value: values.tokenHost, | |
| 699 | required: true, | |
| 700 | placeholder: "forge.example.com", | |
| 701 | autocomplete: "off", | |
| 702 | ...(values.tokenError ? { error: values.tokenError } : {}), | |
| 703 | })} | |
| 704 | ${uiField({ | |
| 705 | label: "Token", | |
| 706 | name: "token", | |
| 707 | id: "f-forge-token", | |
| 708 | type: "password", | |
| 709 | required: true, | |
| 710 | mono: true, | |
| 711 | autocomplete: "off", | |
| 712 | hint: "Read is enough to bring an app; private repositories come with it.", | |
| 713 | })} | |
| 714 | <p class="muted small">Make the token there: ${tokens} (Settings → Access tokens).</p> | |
| 715 | <div class="form-actions">${btn("Connect that forge", { variant: "primary", icon: "globe" })}</div> | |
| 716 | </form> | |
| 717 | ${callbackUri(ctx) === "" | |
| 718 | ? null | |
| 719 | : html`<form method="post" action="/settings/origins/oauth" class="bar mt"> | |
| 720 | <input type="hidden" name="host" value="${values.tokenHost}" /> | |
| 721 | ${btn("Or sign in there", { variant: "outline", icon: "external" })} | |
| 722 | <span class="muted small">One click with the address above; no token to copy.</span> | |
| 723 | </form>`}`; | |
| 724 | } | |
| 725 | ||
| 674 | 726 | /** The admin's list of hosts an origin may reach inside the network. */ |
| 675 | 727 | function trustedHostsCard(text: string, error?: string): Html { |
| 676 | 728 | const fixed = envHosts(); |
| @@ -733,9 +785,7 @@ function tokenNeeds(host: string): Html { | ||
| 733 | 785 | ${link === undefined |
| 734 | 786 | ? null |
| 735 | 787 | : html`<p> |
| 736 | <a href="${link}" target="_blank" rel="noopener noreferrer" | |
| 737 | >Make this token at ${site!.host}</a | |
| 738 | >, with these already chosen. On Gitea or an older Forgejo, open | |
| 788 | ${btn(`Make this token at ${site!.host}`, { href: link, external: true, variant: "link" })}, with these already chosen. On Gitea or an older Forgejo, open | |
| 739 | 789 | Settings → Applications there instead. |
| 740 | 790 | </p>`} |
| 741 | 791 | </div>`; |
| @@ -772,7 +822,7 @@ function originsView( | ||
| 772 | 822 | ? empty({ |
| 773 | 823 | icon: "globe", |
| 774 | 824 | title: "No origins connected", |
| 775 | note: "Connect GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.", | |
| 825 | note: "Connect another forge, GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.", | |
| 776 | 826 | }) |
| 777 | 827 | : html`<div class="stack"> |
| 778 | 828 | ${origins.map((o) => |
| @@ -792,12 +842,18 @@ function originsView( | ||
| 792 | 842 | : null} |
| 793 | 843 | ${card( |
| 794 | 844 | html`<ul class="list"> |
| 845 | ${connectPath( | |
| 846 | "connect-forge", | |
| 847 | "globe", | |
| 848 | html`Another forge ${badge("a friend's", { tone: "success" })}`, | |
| 849 | true, | |
| 850 | forgeSection(ctx, values), | |
| 851 | )} | |
| 795 | 852 | ${connectPath( |
| 796 | 853 | "connect-token", |
| 797 | 854 | "key", |
| 798 | html`Personal access token | |
| 799 | ${badge("recommended", { tone: "success" })}`, | |
| 800 | true, | |
| 855 | "A token for GitHub, Forgejo or Gitea", | |
| 856 | false, | |
| 801 | 857 | tokenSection(values), |
| 802 | 858 | )} |
| 803 | 859 | ${connectPath( |
| @@ -999,12 +1055,33 @@ const connectMessage = connectWords; | ||
| 999 | 1055 | |
| 1000 | 1056 | // POST /settings/origins/oauth |
| 1001 | 1057 | /** |
| 1002 | * Where a connect or a disconnect started, when it was not here: only | |
| 1003 | * `/migrate`, named by a fixed word — never a path a form can choose. | |
| 1058 | * Where a connect or a disconnect started, when it was not here: `/migrate` | |
| 1059 | * or `/bring`, named by a fixed word — never a path a form can choose. | |
| 1004 | 1060 | */ |
| 1005 | export type Then = "/migrate" | undefined; | |
| 1061 | export type Then = "/migrate" | "/bring" | undefined; | |
| 1006 | 1062 | export const thenOf = (value: string | null): Then => |
| 1007 | value === "migrate" ? "/migrate" : undefined; | |
| 1063 | value === "migrate" ? "/migrate" : value === "bring" ? "/bring" : undefined; | |
| 1064 | ||
| 1065 | /** The link a `/bring` started with, kept across a connect: a short http(s) URL or nothing. */ | |
| 1066 | export function linkOf(value: string | null): string | undefined { | |
| 1067 | if (value === null || value.length > 300 || /[\u0000-\u001f\u007f]/.test(value)) return undefined; | |
| 1068 | const url = URL.parse(value); | |
| 1069 | return url !== null && (url.protocol === "https:" || url.protocol === "http:") ? value : undefined; | |
| 1070 | } | |
| 1071 | ||
| 1072 | /** The query that carries `then` (and the link, for `/bring`) through a connect flow. */ | |
| 1073 | export function thenQuery(then: Then, link?: string): string { | |
| 1074 | if (then === undefined) return ""; | |
| 1075 | if (then === "/migrate") return "then=migrate"; | |
| 1076 | return `then=bring${link === undefined ? "" : `&link=${encodeURIComponent(link)}`}`; | |
| 1077 | } | |
| 1078 | ||
| 1079 | /** Where a finished connect returns: the page it began on, with the link kept. */ | |
| 1080 | export function thenTarget(then: Then, link?: string): string { | |
| 1081 | if (then === "/bring") | |
| 1082 | return `/bring?${link === undefined ? "" : `link=${encodeURIComponent(link)}&`}connected=1`; | |
| 1083 | return `${then ?? "/settings/origins"}?connected=1`; | |
| 1084 | } | |
| 1008 | 1085 | |
| 1009 | 1086 | export async function originOAuthStart(ctx: Ctx): Promise<Response> { |
| 1010 | 1087 | const denied = accountOnly(ctx); |
| @@ -1014,6 +1091,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 1014 | 1091 | const kind = field(form, "kind"); |
| 1015 | 1092 | const host = field(form, "host").trim(); |
| 1016 | 1093 | const then = thenOf(field(form, "then")); |
| 1094 | const link = linkOf(field(form, "link")); | |
| 1017 | 1095 | |
| 1018 | 1096 | const back = callbackUri(ctx); |
| 1019 | 1097 | if (back === "" && kind !== "github") { |
| @@ -1047,7 +1125,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 1047 | 1125 | return shell(ctx, originsView(ctx, values), 422); |
| 1048 | 1126 | } |
| 1049 | 1127 | // The flow's page knows where to go from the flow itself, not the link. |
| 1050 | const qs = then === undefined ? "" : "?then=migrate"; | |
| 1128 | const qs = then === undefined ? "" : `?${thenQuery(then, link)}`; | |
| 1051 | 1129 | return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`); |
| 1052 | 1130 | } |
| 1053 | 1131 |
test/bring-connect-return.test.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { linkOf, thenOf, thenQuery, thenTarget } from "../src/web/pages/origins.ts"; | |
| 3 | ||
| 4 | describe("a connect begun on /bring comes back to it with the link kept (#9388)", () => { | |
| 5 | const link = "https://forge-apps.benjivers.com/ben/notes"; | |
| 6 | test("the query carries the link and the target restores it", () => { | |
| 7 | expect(thenOf("bring")).toBe("/bring"); | |
| 8 | expect(thenQuery("/bring", link)).toBe(`then=bring&link=${encodeURIComponent(link)}`); | |
| 9 | expect(thenTarget("/bring", link)).toBe(`/bring?link=${encodeURIComponent(link)}&connected=1`); | |
| 10 | expect(thenTarget("/migrate")).toBe("/migrate?connected=1"); | |
| 11 | expect(thenTarget(undefined)).toBe("/settings/origins?connected=1"); | |
| 12 | }); | |
| 13 | test("only an http(s) link of reasonable size comes back; nothing else is followed", () => { | |
| 14 | expect(linkOf(link)).toBe(link); | |
| 15 | expect(linkOf("javascript:alert(1)")).toBeUndefined(); | |
| 16 | expect(linkOf("//evil.example/x")).toBeUndefined(); | |
| 17 | expect(linkOf(`${link}\n`)).toBeUndefined(); | |
| 18 | expect(linkOf(link.padEnd(400, "a"))).toBeUndefined(); | |
| 19 | expect(linkOf(null)).toBeUndefined(); | |
| 20 | // The return is always a path on this forge, whatever the link says. | |
| 21 | expect(thenTarget("/bring", "https://evil.example/")).toStartWith("/bring?"); | |
| 22 | }); | |
| 23 | }); |
test/bring-oauth-return.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 8 | import { newFakeOAuth, newFakeState, serveFakeForge, type FakeForge, type FakeOAuth } from "./fake-origin.ts"; | |
| 9 | import { withoutEnv } from "./helpers/globals.ts"; | |
| 10 | import { proveAll } from "./helpers/verified.ts"; | |
| 11 | ||
| 12 | /** A sign-in begun from /bring comes back to /bring with the link kept, through the real flow routes (#9388). */ | |
| 13 | ||
| 14 | const PASSWORD = "alice-login-secret"; | |
| 15 | const CLIENT_ID = "Iv1.forge-test-client"; | |
| 16 | const auth = `Basic ${Buffer.from(`alice:${PASSWORD}`).toString("base64")}`; | |
| 17 | withoutEnv("FORGE_URL"); | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let fake: FakeForge; | |
| 23 | let oauth: FakeOAuth; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-bring-oauth-")); | |
| 27 | oauth = newFakeOAuth(CLIENT_ID); | |
| 28 | const state = newFakeState("github", "octo", "unused-pat"); | |
| 29 | state.oauth = oauth; | |
| 30 | fake = serveFakeForge(state); | |
| 31 | process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID; | |
| 32 | app = createApp({ dataDir: dir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | await fetch(`${servers.url}/api/v1/users`, { | |
| 35 | method: "POST", | |
| 36 | headers: { "content-type": "application/json" }, | |
| 37 | body: JSON.stringify({ username: "alice", email: "[email protected]", password: PASSWORD }), | |
| 38 | }); | |
| 39 | proveAll(app); | |
| 40 | forgetProviders(dir); | |
| 41 | }); | |
| 42 | afterAll(async () => { | |
| 43 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 44 | fake.stop(); | |
| 45 | servers.stop(); | |
| 46 | app.close(); | |
| 47 | forgetProviders(dir); | |
| 48 | await rm(dir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | const form = (path: string, body: Record<string, string>) => | |
| 52 | fetch(`${servers.url}${path}`, { | |
| 53 | method: "POST", | |
| 54 | redirect: "manual", | |
| 55 | headers: { authorization: auth, "content-type": "application/x-www-form-urlencoded" }, | |
| 56 | body: new URLSearchParams(body), | |
| 57 | }); | |
| 58 | ||
| 59 | describe("sign in from /bring and come back to it", () => { | |
| 60 | test("start, poll while pending, poll when approved: each step keeps the link", async () => { | |
| 61 | const link = `${fake.url}/octo/widgets`; | |
| 62 | const query = `then=bring&link=${encodeURIComponent(link)}`; | |
| 63 | const started = await form("/settings/origins/oauth", { host: fake.url, kind: "github", then: "bring", link }); | |
| 64 | expect(started.status).toBe(303); | |
| 65 | const flowPage = started.headers.get("location")!; | |
| 66 | expect(flowPage).toContain("/settings/origins/flows/"); | |
| 67 | expect(flowPage).toContain(query); | |
| 68 | const flowId = flowPage.split("/flows/")[1]!.split("?")[0]!; | |
| 69 | ||
| 70 | // The page names where the poll goes, with the return in its query. | |
| 71 | const shown = await fetch(`${servers.url}${flowPage}`, { headers: { authorization: auth } }); | |
| 72 | expect(await shown.text()).toContain(`/settings/origins/flows/${flowId}/poll?${query.replaceAll("&", "&")}`); | |
| 73 | ||
| 74 | // Not approved yet: back to the flow page, still carrying the return. | |
| 75 | const pending = await form(`/settings/origins/flows/${flowId}/poll?${query}`, {}); | |
| 76 | expect(pending.status).toBe(303); | |
| 77 | expect(pending.headers.get("location")).toBe(`/settings/origins/flows/${flowId}?${query}`); | |
| 78 | ||
| 79 | // Approved: the poll lands on /bring with the link and a connected flag. | |
| 80 | oauth.approved = true; | |
| 81 | const done = await form(`/settings/origins/flows/${flowId}/poll?${query}`, {}); | |
| 82 | expect(done.status).toBe(303); | |
| 83 | expect(done.headers.get("location")).toBe(`/bring?link=${encodeURIComponent(link)}&connected=1`); | |
| 84 | ||
| 85 | const back = await fetch(`${servers.url}${done.headers.get("location")!}`, { headers: { authorization: auth } }); | |
| 86 | expect(back.status).toBe(200); | |
| 87 | expect(await back.text()).toContain(`value="${link}"`); | |
| 88 | }); | |
| 89 | }); |
test/bring.test.ts +43 −16
| @@ -5,12 +5,12 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 7 | 7 | import { listAppEnvNames } from "../src/core/app_env.ts"; |
| 8 | import { createOrigin } from "../src/core/origins.ts"; | |
| 8 | import { listOrigins } from "../src/core/origins.ts"; | |
| 9 | 9 | import { getRepo } from "../src/core/repos.ts"; |
| 10 | 10 | import { getUserByName } from "../src/core/users.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { settled } from "../src/origins/jobs.ts"; |
| 13 | import { needsOf, originOnHost, parseBringLink } from "../src/origins/bring.ts"; | |
| 13 | import { connectSite, needsOf, originOnHost, parseBringLink } from "../src/origins/bring.ts"; | |
| 14 | 14 | import { syncsSettled } from "../src/origins/sync.ts"; |
| 15 | 15 | import { SYSTEM_NAMES } from "../src/core/reserved.ts"; |
| 16 | 16 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| @@ -50,6 +50,13 @@ describe("the link and the needs", () => { | ||
| 50 | 50 | expect(originOnHost([], "b.org")).toBeUndefined(); |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | test("the address to connect: https, or http only on a development forge", () => { | |
| 54 | expect(connectSite("https://x.org/a/b", "x.org", {})).toEqual({ ok: true, value: "https://x.org" }); | |
| 55 | expect(connectSite("x.org/a/b", "x.org", {})).toEqual({ ok: true, value: "https://x.org" }); | |
| 56 | expect(connectSite("http://x.org/a/b", "x.org", {})).toEqual({ ok: false, error: "plain_http" }); | |
| 57 | expect(connectSite("http://x.org/a/b", "x.org", { FORGE_DEV: "1" })).toEqual({ ok: true, value: "http://x.org" }); | |
| 58 | }); | |
| 59 | ||
| 53 | 60 | test("needs are names only, from NAME= lines", () => { |
| 54 | 61 | expect( |
| 55 | 62 | needsOf("# keys\nAPI_KEY=abc123\nexport DB_URL = x\nlower=1\nAPI_KEY=again\n\nPORT=3000\n"), |
| @@ -149,27 +156,47 @@ describe("bringing an app whole", () => { | ||
| 149 | 156 | const bad = await post("/bring", { link: "nonsense" }); |
| 150 | 157 | expect(bad.status).toBe(400); |
| 151 | 158 | const none = await post("/bring", { link: `${fake.url}/octo/widgets` }); |
| 152 | expect(none.status).toBe(400); | |
| 153 | expect(await none.text()).toContain("/settings/origins"); | |
| 159 | // Not a dead end (#9388): the page offers to connect in place and keeps the link. | |
| 160 | expect(none.status).toBe(200); | |
| 161 | const card = await none.text(); | |
| 162 | expect(card).toContain('action="/bring/connect"'); | |
| 163 | expect(card).toContain(`value="${fake.url}/octo/widgets"`); | |
| 164 | expect(card).toContain(`${fake.url}/settings/tokens`); | |
| 165 | expect(card).toContain("Sign in there instead"); | |
| 166 | // The sign-in carries the link through and back. | |
| 167 | expect(card).toContain('name="then" value="bring"'); | |
| 168 | // A token the forge refuses keeps the card and the link, and echoes nothing. | |
| 169 | const wrong = await post("/bring/connect", { link: `${fake.url}/octo/widgets`, token: "not-the-token-1" }); | |
| 170 | expect(wrong.status).toBe(422); | |
| 171 | const wrongText = await wrong.text(); | |
| 172 | expect(wrongText).toContain('action="/bring/connect"'); | |
| 173 | expect(wrongText).not.toContain("not-the-token-1"); | |
| 154 | 174 | expect(getRepo(app.db, "alice", "widgets")).toBeUndefined(); |
| 155 | 175 | }); |
| 156 | 176 | |
| 157 | test("a link comes across, private, linked back, asking only by name", async () => { | |
| 158 | const made = createOrigin(app.db, app.config.dataDir, { | |
| 159 | userId: alice().id, | |
| 160 | kind: "forgejo", | |
| 161 | apiUrl: fake.apiUrl, | |
| 162 | webUrl: fake.url, | |
| 163 | login: LOGIN, | |
| 164 | token: TOKEN, | |
| 165 | }); | |
| 166 | if (!made.ok) throw new Error(made.error); | |
| 177 | test("a token is never sent over plain http unless this is a development forge", async () => { | |
| 178 | const before = process.env.FORGE_DEV; | |
| 179 | process.env.FORGE_DEV = "0"; | |
| 180 | try { | |
| 181 | const refused = await post("/bring/connect", { link: `${fake.url}/octo/widgets`, token: TOKEN }); | |
| 182 | expect(refused.status).toBe(400); | |
| 183 | expect(await refused.text()).toContain("travels in clear"); | |
| 184 | expect(listOrigins(app.db, alice().id)).toHaveLength(0); | |
| 185 | } finally { | |
| 186 | if (before === undefined) delete process.env.FORGE_DEV; | |
| 187 | else process.env.FORGE_DEV = before; | |
| 188 | } | |
| 189 | }); | |
| 167 | 190 | |
| 168 | const started = await post("/bring", { link: `${fake.url}/octo/widgets.git` }); | |
| 191 | test("a link comes across, private, linked back, asking only by name", async () => { | |
| 192 | // Connecting in place with the token goes straight on to bringing the link. | |
| 193 | const started = await post("/bring/connect", { link: `${fake.url}/octo/widgets.git`, token: TOKEN }); | |
| 169 | 194 | expect(started.status).toBe(303); |
| 170 | 195 | const where = started.headers.get("location")!; |
| 171 | 196 | const jobId = where.split("/").pop()!; |
| 172 | expect(where).toBe(`/bring/${made.value.id}/${jobId}`); | |
| 197 | const connected = listOrigins(app.db, alice().id); | |
| 198 | expect(connected).toHaveLength(1); | |
| 199 | expect(where).toBe(`/bring/${connected[0]!.id}/${jobId}`); | |
| 173 | 200 | expect((await settled(jobId))?.stage).toBe("done"); |
| 174 | 201 | |
| 175 | 202 | // A GET only looks: the copy is here, nothing is registered or running. |
test/forge-origin-older.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { discover } from "../src/origins/discover.ts"; | |
| 3 | import { providerFor } from "../src/origins/providers/index.ts"; | |
| 4 | import { staticCredential } from "../src/origins/types.ts"; | |
| 5 | ||
| 6 | /** | |
| 7 | * A forge on 0.5.x, as one really answers (#9388): no `/api/v1/version`, no | |
| 8 | * NodeInfo, no `/api/v1/user/repos`; but `/api/v1/health`, `/api/v1/here`, | |
| 9 | * `/api/v1/user` and the cursor-paged `/api/v1/repos` in a forge's own JSON. | |
| 10 | */ | |
| 11 | ||
| 12 | const TOKEN = "friend-token"; | |
| 13 | const repo = (id: number, owner: string, name: string, visibility: string) => ({ | |
| 14 | id: String(id), | |
| 15 | ownerName: owner, | |
| 16 | name, | |
| 17 | description: `${name} app`, | |
| 18 | visibility, | |
| 19 | forkParentId: null, | |
| 20 | defaultBranch: "main", | |
| 21 | }); | |
| 22 | const ALL = [ | |
| 23 | repo(1, "zed", "other", "public"), | |
| 24 | repo(2, "ben", "notes", "public"), | |
| 25 | repo(3, "ben", "secret", "private"), | |
| 26 | repo(4, "zed", "more", "public"), | |
| 27 | repo(5, "ben", "clock", "public"), | |
| 28 | ]; | |
| 29 | ||
| 30 | let server: ReturnType<typeof Bun.serve>; | |
| 31 | let base = ""; | |
| 32 | const seen: string[] = []; | |
| 33 | ||
| 34 | beforeAll(() => { | |
| 35 | process.env.FORGE_DEV = "1"; | |
| 36 | server = Bun.serve({ | |
| 37 | port: 0, | |
| 38 | hostname: "127.0.0.1", | |
| 39 | fetch(req) { | |
| 40 | const url = new URL(req.url); | |
| 41 | seen.push(url.pathname + url.search); | |
| 42 | const json = (v: unknown) => Response.json(v); | |
| 43 | if (url.pathname === "/api/v1/health") | |
| 44 | return json({ ok: true, version: "0.5.29", commit: "abc1234", draining: false }); | |
| 45 | if (url.pathname === "/api/v1/here") | |
| 46 | return json({ here: { kind: "forge", name: "/" }, you: null, actions: [], mcp: {}, docs: [] }); | |
| 47 | const authed = req.headers.get("authorization")?.includes(TOKEN) === true; | |
| 48 | if (url.pathname === "/api/v1/user") | |
| 49 | return authed ? json({ username: "ben", displayName: "Ben" }) : new Response("{}", { status: 401 }); | |
| 50 | if (url.pathname === "/api/v1/repos") { | |
| 51 | if (!authed) return new Response("{}", { status: 401 }); | |
| 52 | const after = Number(url.searchParams.get("cursor") ?? 0); | |
| 53 | const limit = Number(url.searchParams.get("limit") ?? 30); | |
| 54 | const rows = ALL.filter((r) => Number(r.id) > after).slice(0, limit); | |
| 55 | const last = rows[rows.length - 1]; | |
| 56 | return json({ items: rows, next_cursor: rows.length >= limit && last ? last.id : null }); | |
| 57 | } | |
| 58 | return new Response('{"error":"no_route"}', { status: 404 }); | |
| 59 | }, | |
| 60 | }); | |
| 61 | base = `http://127.0.0.1:${server.port}`; | |
| 62 | }); | |
| 63 | afterAll(() => server.stop(true)); | |
| 64 | ||
| 65 | describe("a forge on an older release", () => { | |
| 66 | test("discover recognises it through health and here", async () => { | |
| 67 | const found = await discover(base); | |
| 68 | expect(found.ok).toBe(true); | |
| 69 | if (!found.ok) return; | |
| 70 | expect(found.value.via).toBe("health"); | |
| 71 | expect(found.value.software).toBe("forge 0.5.29"); | |
| 72 | expect(seen).toContain("/api/v1/version"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("something that answers health but is no forge is not one", async () => { | |
| 76 | const other = Bun.serve({ | |
| 77 | port: 0, | |
| 78 | hostname: "127.0.0.1", | |
| 79 | fetch: (req) => | |
| 80 | new URL(req.url).pathname === "/api/v1/health" ? Response.json({ ok: true }) : new Response("no", { status: 404 }), | |
| 81 | }); | |
| 82 | const found = await discover(`http://127.0.0.1:${other.port}`); | |
| 83 | other.stop(true); | |
| 84 | expect(found.ok).toBe(false); | |
| 85 | }); | |
| 86 | ||
| 87 | test("provider forge lists the account's own repositories, private ones too", async () => { | |
| 88 | const provider = providerFor( | |
| 89 | staticCredential({ | |
| 90 | kind: "gitea", | |
| 91 | software: "forge", | |
| 92 | apiUrl: `${base}/api/v1`, | |
| 93 | webUrl: base, | |
| 94 | login: "ben", | |
| 95 | token: TOKEN, | |
| 96 | }), | |
| 97 | ); | |
| 98 | const page = await provider.listRepos(); | |
| 99 | expect(page.ok).toBe(true); | |
| 100 | if (!page.ok) return; | |
| 101 | expect(page.value.items.map((r) => [r.fullName, r.private])).toEqual([ | |
| 102 | ["ben/notes", false], | |
| 103 | ["ben/secret", true], | |
| 104 | ["ben/clock", false], | |
| 105 | ]); | |
| 106 | expect(page.value.next).toBeNull(); | |
| 107 | }); | |
| 108 | }); |
test/tui-bring.test.ts +41
| @@ -68,4 +68,45 @@ describe("the bring screen", () => { | ||
| 68 | 68 | } |
| 69 | 69 | expect(bringWords({ kind: "connect", host: "codeberg.org" })).toContain("codeberg.org"); |
| 70 | 70 | }); |
| 71 | ||
| 72 | test("a link to a forge not yet connected asks for its token, masked, instead of a dead end", async () => { | |
| 73 | const terminal = headlessTerminal(120, 30); | |
| 74 | const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false }); | |
| 75 | tui.start(); | |
| 76 | tui.ctx.push(bringScreen(session, tui.ctx, blank("Bring an app"))); | |
| 77 | try { | |
| 78 | await settleApp(tui); | |
| 79 | terminal.feed("https://friend.example/ana/notes\r"); | |
| 80 | await settleApp(tui); | |
| 81 | const text = tui.screen.toText(); | |
| 82 | expect(text).toContain("Token for friend.example"); | |
| 83 | expect(text).toContain("https://friend.example/settings/tokens"); | |
| 84 | terminal.feed("s3cret-token\r"); | |
| 85 | await settleApp(tui); | |
| 86 | expect(tui.screen.toText()).not.toContain("s3cret-token"); | |
| 87 | } finally { | |
| 88 | tui.stop(); | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("plain http is refused in words, not asked a token for, off a development forge", async () => { | |
| 93 | const before = process.env.FORGE_DEV; | |
| 94 | process.env.FORGE_DEV = "0"; | |
| 95 | const terminal = headlessTerminal(120, 30); | |
| 96 | const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false }); | |
| 97 | tui.start(); | |
| 98 | tui.ctx.push(bringScreen(session, tui.ctx, blank("Bring an app"))); | |
| 99 | try { | |
| 100 | await settleApp(tui); | |
| 101 | terminal.feed("http://friend.example/ana/notes\r"); | |
| 102 | await settleApp(tui); | |
| 103 | const text = tui.screen.toText(); | |
| 104 | expect(text).toContain("plain http"); | |
| 105 | expect(text).not.toContain("Token for friend.example"); | |
| 106 | } finally { | |
| 107 | tui.stop(); | |
| 108 | if (before === undefined) delete process.env.FORGE_DEV; | |
| 109 | else process.env.FORGE_DEV = before; | |
| 110 | } | |
| 111 | }); | |
| 71 | 112 | }); |
test/web-origins.test.ts +11 −9
| @@ -205,12 +205,11 @@ describe("origins on the web", () => { | ||
| 205 | 205 | const html = await (await asUser(alice, "/settings/origins")).text(); |
| 206 | 206 | expect(html).toContain("No origins connected"); |
| 207 | 207 | expect(html).toContain("Forgejo / Gitea, one click"); |
| 208 | expect(html).toContain("Personal access token"); | |
| 209 | // The token path comes first and is open; the OAuth pair is folded away. | |
| 210 | expect(html.indexOf("Personal access token")).toBeLessThan( | |
| 211 | html.indexOf("One-click sign-in"), | |
| 212 | ); | |
| 213 | expect(html).toContain("recommended"); | |
| 208 | // Another forge leads (#9388), with where its token is made; the rest are folded. | |
| 209 | expect(html).toContain("Another forge"); | |
| 210 | expect(html).toContain("/settings/tokens"); | |
| 211 | expect(html.indexOf("Another forge")).toBeLessThan(html.indexOf("A token for GitHub, Forgejo or Gitea")); | |
| 212 | expect(html.indexOf("Another forge")).toBeLessThan(html.indexOf("One-click sign-in")); | |
| 214 | 213 | // Drawn with §5's rows and buttons, not the page's own classes. |
| 215 | 214 | for (const gone of ["handoff", "connect-more", "connect-path"]) { |
| 216 | 215 | expect(html).not.toContain(gone); |
| @@ -240,6 +239,7 @@ describe("origins on the web", () => { | ||
| 240 | 239 | const closed = await (await asUser(alice, "/settings/origins")).text(); |
| 241 | 240 | const used = await (await asUser(alice, "/settings/origins?host=codeberg.org")).text(); |
| 242 | 241 | for (const [id, brand] of [ |
| 242 | ["connect-forge", "globe"], | |
| 243 | 243 | ["connect-token", "key"], |
| 244 | 244 | ["connect-github", "github"], |
| 245 | 245 | ["connect-forgejo", "forgejo"], |
| @@ -254,10 +254,12 @@ describe("origins on the web", () => { | ||
| 254 | 254 | expect(/<span\s+class="row-title"[^>]*>([\s\S]*?)<\/span\s*>/.exec(summary)![1]).toContain(pointAtSprite(icon(brand).text, spriteAsset().path)); |
| 255 | 255 | } |
| 256 | 256 | } |
| 257 | expect(fold(closed, "connect-token").open).toBe(true); | |
| 257 | expect(fold(closed, "connect-forge").open).toBe(true); | |
| 258 | expect(fold(closed, "connect-token").open).toBe(false); | |
| 258 | 259 | expect(fold(closed, "connect-github").open).toBe(false); |
| 259 | 260 | expect(fold(closed, "connect-forgejo").open).toBe(false); |
| 260 | expect(fold(used, "connect-token").open).toBe(true); | |
| 261 | expect(fold(used, "connect-forge").open).toBe(true); | |
| 262 | expect(fold(used, "connect-token").open).toBe(false); | |
| 261 | 263 | expect(fold(used, "connect-github").open).toBe(true); |
| 262 | 264 | expect(fold(used, "connect-forgejo").open).toBe(true); |
| 263 | 265 | }); |
| @@ -869,7 +871,7 @@ describe("origins on the web", () => { | ||
| 869 | 871 | expect(html).not.toContain("error-note"); |
| 870 | 872 | expect(html).not.toContain("origin-callback-err"); |
| 871 | 873 | // The token path is unaffected, and still offered. |
| 872 | expect(html).toContain("Personal access token"); | |
| 874 | expect(html).toContain("Another forge"); | |
| 873 | 875 | |
| 874 | 876 | const started = await fetch(`${base}/settings/origins/oauth`, { |
| 875 | 877 | method: "POST", |
new above
offline
Token goes to an http:// host if the link says so. Refuse it, or require the dev flag.