import { all, first, run } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import type { ScopedDb } from "./scoped-db"; import { locate } from "./scoped-db/locators"; 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"; import { issuesChanged } from "./repo-state"; type Hideable = "repo" | "issue" | "comment" | "review_comment" | "discussion_comment"; type Target = { table: 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" }, issue: { table: "issues" }, comment: { table: "comments", counter: `UPDATE issues SET comments_count = MAX(comments_count + ?, 0) WHERE id = (SELECT issue_id FROM comments WHERE id = ?)`, }, review_comment: { table: "review_comments" }, discussion_comment: { table: "discussion_comments", 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); } type Store = Pick; const directory: Store = { first, run }; /** Where the row lives: repositories in the D1 directory, everything else in its repository's object. */ async function storeFor(kind: Hideable, id: string): Promise<{ db: Store; repoId: string } | null> { if (kind === "repo") return { db: directory, repoId: id }; const repoId = await locate(kind, id); return repoId ? { db: repoDb(repoId), repoId } : null; } /** An open issue or pull request leaves (or rejoins) the repository's open counter as it is hidden (or restored). */ async function adjustOpenCounter(repoId: string, issueId: string, delta: number): Promise { const issue = await repoDb(repoId).first<{ is_pull: number }>( "SELECT 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, repoId); } /** Drops inbox rows that point at a hidden issue or pull request, so its title stops showing in notifications. */ async function purgeIssueNotifications(repoId: string, issueId: string): Promise { const [issue, repo] = await Promise.all([ repoDb(repoId).first<{ number: number; is_pull: number }>( "SELECT number, is_pull FROM issues WHERE id = ?", issueId, ), first<{ owner: string; name: string }>( "SELECT a.name AS owner, r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?", repoId, ), ]); if (!issue || !repo) return; const url = publicUrl(`/${repo.owner}/${repo.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<{ id: string }>("SELECT account_id AS id FROM users"); const touched: string[] = []; for (const user of users) { const removed = await accountDb(user.id).run(`DELETE FROM notifications WHERE ${match}`, repoId, url); if (removed.meta.changes > 0) touched.push(user.id); } await invalidateUnread(touched); } /** * 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]; const store = await storeFor(kind, id); if (!store || !(await store.db.first(`SELECT 1 AS ok FROM ${target.table} WHERE id = ?`, id))) { throw notFoundError("Target not found"); } const changed = await store.db.run( `UPDATE ${target.table} SET hidden_at = ? WHERE id = ? AND hidden_at IS ${hidden ? "NULL" : "NOT NULL"}`, hidden ? Date.now() : null, id, ); if (changed.meta.changes) { if (target.counter) await store.db.run(target.counter, hidden ? -1 : 1, id); if (kind === "issue") { await adjustOpenCounter(store.repoId, id, hidden ? -1 : 1); if (hidden) await purgeIssueNotifications(store.repoId, id); issuesChanged(store.repoId, [id]); } if (kind === "repo") await dropMcpAccess(id); } const action = `${kind}.${hidden ? "hide" : "unhide"}`; await recordAdminAction({ actorId, action, targetKind: kind, targetId: id, reason, }); }