import { env } from "cloudflare:workers"; import { all, chunks, first, placeholders } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { locate } from "./scoped-db/locators"; import { canCreateInAccount, visibleReposClause } from "./repos"; import { viewerFromSessionCookie } from "./auth"; import { limitBrowse, rawRequestIp } from "./limits"; import { HttpError, forbidden, notFoundError } from "./errors"; import type { Viewer } from "~/lib/types"; const LIVE_PATH = /^\/projects\/([^/]+)\/(\d+)\/live$/; export type ProjectRow = { id: string; owner_id: string; number: number; title: string; description: string; visibility: "public" | "private"; closed: number; created_by: string; created_at: number; updated_at: number; }; export type ProjectFieldRow = { id: string; project_id: string; name: string; kind: "text" | "number" | "date" | "single_select"; options: string; position: number; }; export type ProjectAccess = { project: ProjectRow; ownerName: string; canWrite: boolean; }; export async function loadProjectAccess( ownerName: string, number: number, viewer: Viewer | null, ): Promise { const account = await first<{ id: string; name: string }>("SELECT id, name FROM accounts WHERE name = ?", ownerName); const project = account ? await accountDb(account.id).first( "SELECT * FROM projects WHERE owner_id = ? AND number = ?", account.id, number, ) : null; if (!account || !project) throw notFoundError("Project not found"); const canWrite = await canCreateInAccount(project.owner_id, viewer); if (project.visibility === "private" && !canWrite) throw notFoundError("Project not found"); return { project, ownerName: account.name, canWrite }; } export async function requireProjectWrite(ownerName: string, number: number, viewer: Viewer): Promise { const access = await loadProjectAccess(ownerName, number, viewer); if (!access.canWrite) throw forbidden("Only members of this account can edit the project"); return access; } export function parseOptions(field: Pick): string[] { const parsed: unknown = JSON.parse(field.options); return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []; } /** `ownerId` skips the locator lookup when the caller already has the project's account. */ export async function projectFields(projectId: string, ownerId?: string): Promise { const scope = ownerId ?? (await locate("project", projectId)); if (!scope) return []; return accountDb(scope).all( "SELECT * FROM project_fields WHERE project_id = ? ORDER BY position", projectId, ); } export type ProjectIssue = { id: string; repo_id: string; number: number; title: string; state: "open" | "closed"; is_pull: number; repo_owner: string; repo_name: string; }; /** * The issues behind project items (which may come from any repository) that the viewer may see, by issue id. * An item whose issue is missing from the map is hidden from this viewer, so private-repo items never leak * through a project; note-only items (no issue) are always visible. */ export async function visibleProjectIssues( issueIds: Array, viewer: Viewer | null, ): Promise> { const out = new Map(); const byRepo = new Map(); for (const id of new Set(issueIds.filter((value): value is string => value !== null))) { const repoId = await locate("issue", id); if (repoId) byRepo.set(repoId, [...(byRepo.get(repoId) ?? []), id]); } if (byRepo.size === 0) return out; const { clause, viewerId } = visibleReposClause(viewer); const repoIds = [...byRepo.keys()]; const repos = await all<{ id: string; owner: string; name: string }>( `SELECT r.id, a.name AS owner, r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE ${clause} AND r.id IN (${placeholders(repoIds.length)})`, viewerId, ...repoIds, ); for (const repo of repos) { for (const ids of chunks(byRepo.get(repo.id) ?? [])) { const issues = await repoDb(repo.id).all>( `SELECT id, repo_id, number, title, state, is_pull FROM issues WHERE id IN (${placeholders(ids.length)}) AND hidden_at IS NULL`, ...ids, ); for (const issue of issues) out.set(issue.id, { ...issue, repo_owner: repo.owner, repo_name: repo.name, }); } } return out; } /** Tells every open board for this project to refresh. */ export async function notifyProject(projectId: string, actor: string): Promise { const room = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(projectId)); await room.notifyChanged(actor); } async function live(request: Request, match: RegExpExecArray): Promise { if (request.headers.get("upgrade") !== "websocket") return new Response("Expected websocket", { status: 426 }); const origin = request.headers.get("origin"); if (origin && origin !== new URL(request.url).origin) return new Response("Cross-origin socket", { status: 403 }); const viewer = await viewerFromSessionCookie(request); await limitBrowse(viewer, request); let owner: string; try { owner = decodeURIComponent(match[1]); } catch { return new Response("Bad owner", { status: 400 }); } const access = await loadProjectAccess(owner, Number(match[2]), viewer); const room = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(access.project.id)); const headers = new Headers(request.headers); headers.set("x-gitfor-viewer", viewer?.name ?? "anonymous"); headers.set("x-gitfor-client", viewer ? `user:${viewer.id}` : `ip:${rawRequestIp(request)}`); return room.fetch(new Request(request.url, { headers })); } /** WebSocket endpoint for live project collaboration (`/projects///live`). */ export async function handleProjectRoutes(request: Request, url: URL): Promise { const match = LIVE_PATH.exec(url.pathname); if (!match) return null; try { return await live(request, match); } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.status }); throw error; } }