import { env } from "cloudflare:workers"; import { viewerFromSessionCookie } from "../auth"; import { first } from "../db"; import { randomToken, sha256Hex } from "../crypto"; import { HttpError } from "../errors"; import { limitWrite } from "../limits"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import { codeKey, labelUrl, siteOrigin } from "./config"; import { CALLBACK_PATH } from "./access"; import { resolveSiteHost } from "./dispatch"; import { claimScratchSite } from "./scratch"; import { html } from "./static"; import { claimPage } from "./claim-page"; import type { SiteMeta } from "./config"; import type { Viewer } from "~/lib/types"; const CODE_TTL_SECONDS = 60; function page(status: number, title: string, body: string): Response { return html( status, `${title}

${title}

${body}`, ); } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); } function signInFirst(): Response { return page( 401, "Sign in to continue", `

Sign in to ${escapeHtml(env.FORGE_NAME)} in this browser, then return to the site and try again.

Sign in

`, ); } async function allowed(site: SiteMeta, viewer: Viewer): Promise { if (!site.repoId) return site.ownerId === viewer.id; const repo = await loadRepoById(site.repoId); if (!repo) return false; const permission = await repoPermission(repo, viewer); if (site.audience === "readers") return hasPermission(permission, "read"); if (hasPermission(permission, "write")) return true; const member = await first<{ user_id: string }>( "SELECT user_id FROM org_members WHERE org_id = ? AND user_id = ?", repo.owner_id, viewer.id, ); return member !== null; } /** "Sign in with gitfor": the forge is the IdP; it hands the site host a single-use code, never its session. */ async function authorize(request: Request, url: URL): Promise { const viewer = await viewerFromSessionCookie(request); if (!viewer) return signInFirst(); const params = request.method === "POST" ? new URLSearchParams(await request.text()) : url.searchParams; const siteId = params.get("site") ?? ""; const host = (params.get("host") ?? "").toLowerCase(); const state = params.get("state") ?? ""; if (!/^[A-Za-z0-9_]{8,100}$/.test(state)) return page(400, "Invalid request", "

Missing sign-in state.

"); const resolved = await resolveSiteHost(host.replace(/:\d+$/, "")); if (!resolved || resolved.meta.id !== siteId || resolved.meta.access !== "gitfor") { return page(400, "Invalid request", "

That site does not use gitfor sign-in.

"); } if (!(await allowed(resolved.meta, viewer))) { return page(403, "Access denied", `

@${escapeHtml(viewer.name)} is not allowed to view this site.

`); } if (request.method !== "POST") { const fields = [ ["site", siteId], ["host", host], ["state", state], ] .map(([key, value]) => ``) .join(""); return page( 200, "Continue to site", `

${escapeHtml(host)} wants to know you are @${escapeHtml(viewer.name)} on ${escapeHtml(env.FORGE_NAME)}. It will see your username, and nothing else.

${fields}
`, ); } const code = randomToken("sc"); await env.KV.put(codeKey(await sha256Hex(code)), JSON.stringify({ siteId, host, subject: viewer.name }), { expirationTtl: CODE_TTL_SECONDS, }); const target = new URL(CALLBACK_PATH, siteOrigin(host.replace(/:\d+$/, ""))); target.searchParams.set("code", code); target.searchParams.set("state", state); return new Response(null, { status: 303, headers: { location: target.toString(), "cache-control": "no-store" } }); } async function claim(request: Request, url: URL): Promise { const viewer = await viewerFromSessionCookie(request); if (!viewer) return signInFirst(); if (request.method === "GET") { // `?token=` is still accepted for links issued before the token moved into the fragment. return claimPage(viewer.name, url.searchParams.get("token") ?? "", randomToken("n").replace(/[^A-Za-z0-9]/g, "")); } await limitWrite(viewer); const form = await request.formData(); try { const site = await claimScratchSite(String(form.get("token") ?? ""), viewer, form.get("public") === "on"); const link = labelUrl(site.label) ?? ""; return page( 200, "Site claimed", `

Your site now lives at ${escapeHtml(link)}.

`, ); } catch (error) { if (error instanceof HttpError) return page(error.status, "Could not claim site", `

${escapeHtml(error.message)}

`); throw error; } } /** Forge-origin routes for sites: the IdP authorize endpoint and the scratch claim page. */ export async function handleSitesForgeRoutes(request: Request, url: URL): Promise { if (url.pathname === "/sites/authorize" && (request.method === "GET" || request.method === "POST")) { return authorize(request, url); } if (url.pathname === "/sites/claim" && (request.method === "GET" || request.method === "POST")) { return claim(request, url); } return null; }