import { env } from "cloudflare:workers"; import { all, first } from "./db"; import { canCreateInAccount, visibleReposClause } from "./repos"; import { viewerFromSessionCookie } from "./auth"; import { limitBrowse } 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 row = await first( "SELECT p.*, a.name AS owner_name FROM projects p JOIN accounts a ON a.id = p.owner_id WHERE a.name = ? AND p.number = ?", ownerName, number, ); if (!row) throw notFoundError("Project not found"); const canWrite = await canCreateInAccount(row.owner_id, viewer); if (row.visibility === "private" && !canWrite) throw notFoundError("Project not found"); const { owner_name: owner, ...project } = row; return { project, ownerName: owner, 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") : []; } export async function projectFields(projectId: string): Promise { return all("SELECT * FROM project_fields WHERE project_id = ? ORDER BY position", projectId); } /** Issue ids in the project that the viewer may see, so private-repo items never leak through a project. */ export function visibleItemClause(viewer: Viewer | null): { clause: string; viewerId: string } { const { clause, viewerId } = visibleReposClause(viewer); return { clause: `(pi.issue_id IS NULL OR EXISTS (SELECT 1 FROM repos r WHERE r.id = i.repo_id AND ${clause}))`, viewerId, }; } /** 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-stig-viewer", viewer?.name ?? "anonymous"); 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; } }