import { all, first, run } from "./db"; import { badRequest, notFoundError } from "./errors"; import { recordAdminAction } from "./admin-audit"; import { publicUrl } from "./events/emit"; import { invalidateUnread } from "./user-inbox"; import { dropMcpAccess } from "./access-revocation"; import { HIDEABLE_KINDS } from "~/lib/abuse"; import type { AbuseTargetKind } from "~/lib/abuse"; type Hideable = "repo" | "issue" | "comment" | "review_comment" | "discussion_comment"; type Target = { table: string; key: string; column: string; counter?: string }; /** `counter` keeps the parent's comment count in step, so hidden comments don't show up as a number either. */ const TARGETS: Record = { repo: { table: "repos", key: "id", column: "hidden_at" }, issue: { table: "issues", key: "id", column: "hidden_at" }, comment: { table: "comments", key: "id", column: "hidden_at", counter: `UPDATE issues SET comments_count = MAX(comments_count + ?, 0) WHERE id = (SELECT issue_id FROM comments WHERE id = ?)`, }, review_comment: { table: "review_comments", key: "id", column: "hidden_at" }, discussion_comment: { table: "discussion_comments", key: "id", column: "hidden_at", counter: `UPDATE discussions SET comments_count = MAX(comments_count + ?, 0) WHERE id = (SELECT discussion_id FROM discussion_comments WHERE id = ?)`, }, }; export function isHideable(kind: string): kind is Hideable { return HIDEABLE_KINDS.has(kind as AbuseTargetKind); } /** An open issue or pull request leaves (or rejoins) the repository's open counter as it is hidden (or restored). */ async function adjustOpenCounter(issueId: string, delta: number): Promise { const issue = await first<{ repo_id: string; is_pull: number }>( "SELECT repo_id, is_pull FROM issues WHERE id = ? AND state = 'open'", issueId, ); if (!issue) return; const counter = issue.is_pull ? "open_pulls_count" : "open_issues_count"; await run(`UPDATE repos SET ${counter} = MAX(${counter} + ?, 0) WHERE id = ?`, delta, issue.repo_id); } /** Drops inbox rows that point at a hidden issue or pull request, so its title stops showing in notifications. */ async function purgeIssueNotifications(issueId: string): Promise { const issue = await first<{ repo_id: string; number: number; is_pull: number; owner: string; name: string }>( `SELECT i.repo_id, i.number, i.is_pull, a.name AS owner, r.name FROM issues i JOIN repos r ON r.id = i.repo_id JOIN accounts a ON a.id = r.owner_id WHERE i.id = ?`, issueId, ); if (!issue) return; const url = publicUrl(`/${issue.owner}/${issue.name}/${issue.is_pull ? "pull" : "issues"}/${issue.number}`); const match = "repo_id = ?1 AND (url = ?2 OR url LIKE ?2 || '#%' OR url LIKE ?2 || '/%')"; const users = await all<{ user_id: string }>( `SELECT DISTINCT user_id FROM notifications WHERE ${match}`, issue.repo_id, url, ); await run(`DELETE FROM notifications WHERE ${match}`, issue.repo_id, url); await invalidateUnread(users.map((row) => row.user_id)); } /** * Hides one repository, issue, comment, review comment or discussion comment from everyone but site admins. * Reversible; every call is audited. */ export async function setModerationHidden( kind: string, id: string, hidden: boolean, actorId: string, reason: string | null = null, ): Promise { if (!isHideable(kind)) throw badRequest("This kind of target can't be hidden"); const target = TARGETS[kind]; if (!(await first(`SELECT 1 AS ok FROM ${target.table} WHERE ${target.key} = ?`, id))) { throw notFoundError("Target not found"); } const changed = await run( `UPDATE ${target.table} SET ${target.column} = ? WHERE ${target.key} = ? AND ${target.column} IS ${hidden ? "NULL" : "NOT NULL"}`, hidden ? Date.now() : null, id, ); if (changed.meta.changes) { if (target.counter) await run(target.counter, hidden ? -1 : 1, id); if (kind === "issue") await adjustOpenCounter(id, hidden ? -1 : 1); if (kind === "issue" && hidden) await purgeIssueNotifications(id); if (kind === "repo") await dropMcpAccess(id); } const action = `${kind}.${hidden ? "hide" : "unhide"}`; await recordAdminAction({ actorId, action, targetKind: kind, targetId: id, reason }); }