import { env } from "cloudflare:workers"; import { run } from "./db"; import { TERMS_VERSION } from "~/lib/legal"; import type { LegalInfo } from "~/lib/legal"; const SECURITY_TXT_PATH = "/.well-known/security.txt"; /** RFC 9116 asks for an Expires under a year; one day short also covers the cache lifetime. */ const SECURITY_TXT_LIFETIME_MS = 364 * 24 * 60 * 60 * 1000; const SECURITY_TXT_CACHE_SECONDS = 60 * 60 * 24; function value(raw: string | undefined): string | null { const trimmed = raw?.trim(); return trimmed ? trimmed : null; } export function legalInfo(): LegalInfo { const info = { forgeName: value(env.FORGE_NAME) ?? "gitfor", publicUrl: value(env.PUBLIC_URL) ?? "", entity: value(env.LEGAL_ENTITY), contactEmail: value(env.LEGAL_CONTACT_EMAIL), address: value(env.LEGAL_ADDRESS), jurisdiction: value(env.LEGAL_JURISDICTION), dmcaEmail: value(env.DMCA_AGENT_EMAIL), securityEmail: value(env.SECURITY_CONTACT_EMAIL), supportEmail: value(env.SUPPORT_EMAIL), }; const draft = [info.entity, info.contactEmail, info.jurisdiction, info.dmcaEmail].includes(null); return { ...info, draft }; } /** Signup (password or GitHub) is acceptance of the Terms and Privacy Policy shown next to the button. */ export async function recordTermsAcceptance(userId: string): Promise { await run( "UPDATE users SET terms_version = ?, terms_accepted_at = ? WHERE account_id = ?", TERMS_VERSION, Date.now(), userId, ); } /** RFC 9116 body, or null when there is no contact to publish (Contact is a required field). */ export function securityTxt(info: LegalInfo, now: number): string | null { const contact = info.securityEmail ?? info.contactEmail; const origin = info.publicUrl.replace(/\/+$/, ""); if (!contact && !origin) return null; const lines = [ ...(contact ? [`Contact: mailto:${contact}`] : []), ...(origin ? [`Contact: ${origin}/security`] : []), `Expires: ${new Date(now + SECURITY_TXT_LIFETIME_MS).toISOString().replace(/\.\d{3}Z$/, "Z")}`, ...(origin ? [`Policy: ${origin}/security`, `Canonical: ${origin}${SECURITY_TXT_PATH}`] : []), "Preferred-Languages: en", ]; return `${lines.join("\n")}\n`; } export function handleSecurityTxt(request: Request, url: URL): Response | null { if (url.pathname !== SECURITY_TXT_PATH) return null; if (request.method !== "GET" && request.method !== "HEAD") { return new Response("Method not allowed\n", { status: 405, headers: { allow: "GET, HEAD" } }); } const body = securityTxt(legalInfo(), Date.now()); if (body === null) return new Response("Not found\n", { status: 404 }); return new Response(request.method === "HEAD" ? null : body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": `public, max-age=${SECURITY_TXT_CACHE_SECONDS}`, }, }); }