import { env } from "cloudflare:workers"; import { first } from "./db"; import { repoDb } from "./scoped-db"; import { sitesDomain } from "./sites/config"; import { hostHolder, loadSiteRow } from "./sites/store"; import { hostLabel } from "./sites/naming"; import type { AbuseTargetKind } from "~/lib/abuse"; export type AbuseTarget = { kind: AbuseTargetKind; id: string }; const COMMENT_ANCHOR = /^#(?:issue|review-|discussion-)?comment-([A-Za-z0-9_-]{1,64})$/; const ISSUE_PATH = /^(issues|pull)$/; function parseUrl(targetUrl: string): URL | null { try { return new URL(targetUrl); } catch { return null; } } /** Maps a reported URL to the hosted site behind it: site labels, preview labels, then verified custom domains. */ export async function siteIdForUrl(targetUrl: string): Promise { const host = parseUrl(targetUrl)?.hostname.toLowerCase(); if (!host) return null; const domain = sitesDomain(); const label = domain ? hostLabel(host, domain) : null; // Production labels, preview labels and custom domains are all registered as site_host -> site id. const siteId = await hostHolder(label ?? host); if (!siteId) return null; return (await loadSiteRow(siteId)) ? siteId : null; } function isForgeUrl(url: URL): boolean { try { return url.hostname.toLowerCase() === new URL(env.PUBLIC_URL).hostname.toLowerCase(); } catch { return false; } } function segments(url: URL): string[] { try { return url.pathname.split("/").filter(Boolean).map(decodeURIComponent); } catch { return []; } } async function discussionTarget(repoId: string, number: number, hash: string): Promise { const commentId = COMMENT_ANCHOR.exec(hash)?.[1]; if (!commentId) return null; const comment = await repoDb(repoId).first<{ id: string }>( `SELECT c.id FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id WHERE c.id = ? AND d.repo_id = ? AND d.number = ?`, commentId, repoId, number, ); return comment ? { kind: "discussion_comment", id: comment.id } : null; } /** Forge pages: `/`, `//`, `///issues/` and a `#comment-` anchor on it. */ async function forgeTarget(url: URL): Promise { const [owner, repoName, section, number] = segments(url); if (!owner) return null; if (!repoName) { const account = await first<{ id: string }>("SELECT id FROM accounts WHERE name = ? COLLATE NOCASE", owner); return account ? { kind: "user", id: account.id } : null; } const repo = await first<{ id: string }>( "SELECT r.id FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ? COLLATE NOCASE AND r.name = ?", owner, repoName.replace(/\.git$/, ""), ); if (!repo) return null; const threadNumber = Number(number); if (section === "discussions" && Number.isInteger(threadNumber) && threadNumber > 0) { return ( (await discussionTarget(repo.id, threadNumber, url.hash)) ?? { kind: "repo", id: repo.id, } ); } const issueNumber = section && ISSUE_PATH.test(section) ? threadNumber : Number.NaN; if (!Number.isInteger(issueNumber) || issueNumber <= 0) return { kind: "repo", id: repo.id }; const db = repoDb(repo.id); const issue = await db.first<{ id: string }>( "SELECT id FROM issues WHERE repo_id = ? AND number = ?", repo.id, issueNumber, ); if (!issue) return { kind: "repo", id: repo.id }; const commentId = COMMENT_ANCHOR.exec(url.hash)?.[1]; if (commentId) { const comment = await db.first<{ id: string }>( "SELECT id FROM comments WHERE id = ? AND issue_id = ?", commentId, issue.id, ); if (comment) return { kind: "comment", id: comment.id }; const review = await db.first<{ id: string }>( "SELECT id FROM review_comments WHERE id = ? AND issue_id = ?", commentId, issue.id, ); if (review) return { kind: "review_comment", id: review.id }; } return { kind: "issue", id: issue.id }; } /** What a reported URL points at: a hosted site or a forge repo, user, issue or comment. */ export async function resolveAbuseTarget(targetUrl: string): Promise { const url = parseUrl(targetUrl); if (!url || (url.protocol !== "https:" && url.protocol !== "http:")) return null; const siteId = await siteIdForUrl(targetUrl); if (siteId) return { kind: "site", id: siteId }; if (isForgeUrl(url)) return forgeTarget(url); return null; }