diff --git a/server/src/pdsClient.ts b/server/src/pdsClient.ts index b674b72..df4bd82 100644 --- a/server/src/pdsClient.ts +++ b/server/src/pdsClient.ts @@ -287,6 +287,81 @@ export class PdsClient { return this.countBody(res); } + /** + * Permanently delete every record in an account's repo while keeping the account + * itself, so the handle stays taken. The admin API can't write to user repos, so: + * reset the account's password, lift the takedown just long enough to sign in as + * the account and delete records in batches, then re-apply the takedown (also on + * failure). Blobs are not touched. Accounts with email 2FA will fail the sign-in; + * the error surfaces to the caller and the takedown is restored. + */ + async purgeAllRecords(did: string): Promise<{ deleted: number }> { + const { password } = await this.resetAccountPassword(did); + await this.setAccountTakedown(did, false); + try { + const sessionRes = await fetch( + `https://${this.hostname}/xrpc/com.atproto.server.createSession`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: did, password }), + }, + ); + if (!sessionRes.ok) { + const body = await sessionRes.text().catch(() => ""); + throw new Error(`purge sign-in as ${did} failed: ${sessionRes.status} ${body}`); + } + const { accessJwt } = (await sessionRes.json()) as { accessJwt: string }; + const userXrpc = async (path: string, opts: RequestInit = {}): Promise => { + const res = await fetch(`https://${this.hostname}/xrpc/${path}`, { + ...opts, + headers: { + ...opts.headers, + Authorization: `Bearer ${accessJwt}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`purge ${path} failed: ${res.status} ${body}`); + } + return res.json(); + }; + + const repo = encodeURIComponent(did); + const { collections } = (await userXrpc(`com.atproto.repo.describeRepo?repo=${repo}`)) as { + collections: string[]; + }; + let deleted = 0; + for (const collection of collections) { + // delete page by page from the start until the collection is empty + for (let batch = 0; ; batch++) { + if (batch > 10_000) throw new Error(`purge of ${collection} for ${did} not converging`); + const { records } = (await userXrpc( + `com.atproto.repo.listRecords?repo=${repo}&collection=${encodeURIComponent(collection)}&limit=100`, + )) as { records: { uri: string }[] }; + if (records.length === 0) break; + await userXrpc("com.atproto.repo.applyWrites", { + method: "POST", + body: JSON.stringify({ + repo: did, + writes: records.map((r) => ({ + $type: "com.atproto.repo.applyWrites#delete", + collection, + rkey: r.uri.split("/").pop(), + })), + }), + }); + deleted += records.length; + } + } + return { deleted }; + } finally { + // the account must never stay enabled, restore the takedown no matter what + await this.setAccountTakedown(did, true); + } + } + async getInviteCodes(limit = 100): Promise<{ codes: InviteCode[] }> { return this.xrpc(`com.atproto.admin.getInviteCodes?sort=recent&limit=${limit}`) as Promise<{ codes: InviteCode[]; diff --git a/server/src/routes/accounts.ts b/server/src/routes/accounts.ts index 9c8bffa..11c4c2f 100644 --- a/server/src/routes/accounts.ts +++ b/server/src/routes/accounts.ts @@ -139,6 +139,23 @@ export function registerAccountRoutes( return { ok: true }; }); + app.post("/api/accounts/:did/purge-records", { preHandler: requireAuth }, async (req, reply) => { + const { did } = req.params as { did: string }; + // purging is permanent, so only allowed on accounts already taken down: the + // takedown step is the reversible part of the flow, this one is not. The account + // itself survives so the handle stays taken + const row = db.prepare("SELECT status FROM accounts WHERE did = ?").get(did) as + | { status: string } + | undefined; + if (!row) return reply.code(404).send({ error: "account not found" }); + if (row.status !== "takendown") { + return reply.code(409).send({ error: "account must be taken down before purging records" }); + } + const { deleted } = await pds.purgeAllRecords(did); + await recordAction({ operator: req.session.operator!, action: "purge-records", target: did }); + return { ok: true, deleted }; + }); + app.post("/api/accounts/:did/reset-password", { preHandler: requireAuth }, async (req) => { const { did } = req.params as { did: string }; const { password } = await pds.resetAccountPassword(did); diff --git a/web/src/AccountsPanel.tsx b/web/src/AccountsPanel.tsx index 830668b..89134c9 100644 --- a/web/src/AccountsPanel.tsx +++ b/web/src/AccountsPanel.tsx @@ -39,7 +39,10 @@ export function AccountsPanel({ panelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, [searchFor]); const [busyDid, setBusyDid] = useState(null); - const [confirm, setConfirm] = useState<{ did: string; action: "status" | "reset" } | null>(null); + const [confirm, setConfirm] = useState<{ + did: string; + action: "status" | "reset" | "delete"; + } | null>(null); const [menuDid, setMenuDid] = useState(null); const [passwordResult, setPasswordResult] = useState<{ handle: string; password: string } | null>( null, @@ -137,7 +140,10 @@ export function AccountsPanel({ .finally(() => setLoading(false)); }; - const runAction = async (did: string, action: "takedown" | "enable" | "resetPassword") => { + const runAction = async ( + did: string, + action: "takedown" | "enable" | "resetPassword" | "purgeRecords", + ) => { setBusyDid(did); setConfirm(null); try { @@ -237,12 +243,15 @@ export function AccountsPanel({ onClick={() => { setMenuDid(null); if (confirm.action === "reset") runAction(a.did, "resetPassword"); + else if (confirm.action === "delete") runAction(a.did, "purgeRecords"); else runAction(a.did, a.status === "takendown" ? "enable" : "takedown"); }} > {confirm.action === "reset" ? "confirm reset password" - : `confirm ${a.status === "takendown" ? "enable" : "takedown"}`} + : confirm.action === "delete" + ? "confirm delete forever" + : `confirm ${a.status === "takendown" ? "enable" : "takedown"}`} + {a.status === "takendown" && ( + + )} )} diff --git a/web/src/api.ts b/web/src/api.ts index a309c5e..0648528 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -124,6 +124,7 @@ export const api = { }, takedown: (did: string) => req<{ ok: true }>(`/api/accounts/${encodeURIComponent(did)}/takedown`, { method: "POST" }), enable: (did: string) => req<{ ok: true }>(`/api/accounts/${encodeURIComponent(did)}/enable`, { method: "POST" }), + purgeRecords: (did: string) => req<{ ok: true; deleted: number }>(`/api/accounts/${encodeURIComponent(did)}/purge-records`, { method: "POST" }), resetPassword: (did: string) => req<{ ok: true; password: string }>(`/api/accounts/${encodeURIComponent(did)}/reset-password`, { method: "POST" }), cursorStatus: () => req("/api/status/cursor"), requestCrawl: (relay?: string) =>