Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ Keys are scoped to one channel and one owner, stored only as a SHA-256 hash, and
`canManage` on every request, so demoting an owner disables their keys without an explicit
revocation. Deletions reuse `logDelete`/`publicLogDelete` and are attributed to the key's owner.
Per-key throughput is capped at 60 requests per minute in memory, and batches at 50 messages.
`POST /api/v1/threads/delete` takes a `thread_ts` and runs `purge()`, the same path as the
`destroy_thread` shortcut.

Two properties are load-bearing. `LOG_CHANNEL` is mandatory for the endpoint: `logDelete` treats an
unset channel as a successful no-op, so without this guard the API would delete messages with no
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ curl -X POST https://prometheus.hackclub.com/api/v1/messages/delete \

A `200` means the request was accepted, not that every message was deleted, so check `deleted` and `failed`, where each failure carries the Slack error (`message_not_found`, `cant_delete_message`).

To delete a whole thread (root message plus every reply):

```bash
curl -X POST https://prometheus.hackclub.com/api/v1/threads/delete \
-H "Authorization: Bearer $PROMETHEUS_KEY" \
-H "Content-Type: application/json" \
-d '{"thread_ts":"1699999999.123456","reason":"spam thread"}'
```

```json
{ "ok": true, "channel": "C0123ABCD", "thread_ts": "1699999999.123456" }
```

| Field | Required | Notes |
| ----------- | -------- | ----------------------------------------------- |
| `thread_ts` | Yes | Timestamp of the thread's root message |
| `reason` | Yes | Up to 500 characters, recorded in the audit log |

Deletion runs to completion server-side and keeps paging until the thread is empty; a `502 purge_failed` means it could not finish.

Other statuses: `400` malformed request, `401` missing or invalid key, `403` no permission, `413` request body over 64 KB, `429` over the rate limit of 60 requests per minute.

`GET /api/v1/key` just returns a key's metadata, which is handy for checking the status of a key.
Expand Down
40 changes: 29 additions & 11 deletions lib/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ export async function logKeyDisclosure(client, { owner, keyName, keyPrefix, sour
});
}

export async function logThread(client, logger, { channel, threadTs, messages, deletedBy, api }) {
export async function logThread(
client,
logger,
{ channel, threadTs, messages, deletedBy, reason, api },
) {
if (!LOG_CHANNEL) return; // ding dong didnt set it up

let cdnUrl = null;
Expand Down Expand Up @@ -212,19 +216,33 @@ export async function logThread(client, logger, { channel, threadTs, messages, d
}
}

const cdnLine = cdnUrl ? `\n*Archive:* ${cdnUrl}` : "\n_CDN upload skipped or failed_";
const cdnLine = cdnUrl ? `*Archive:* ${cdnUrl}` : "_CDN upload skipped or failed_";

const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `:fire: <@${deletedBy}> destroyed a thread in <#${channel}> with ${messages.length} messages.${apiTag(api)}`, // todo replace with better emoji
},
},
];

if (reason) {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: `*Reason:* ${reason}` },
});
}

blocks.push({
type: "section",
text: { type: "mrkdwn", text: cdnLine },
});

await getLogClient(client).chat.postMessage({
channel: LOG_CHANNEL,
text: `Thread deleted in <#${channel}>`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:fire: <@${deletedBy}> destroyed a thread in <#${channel}> with ${messages.length} messages.${apiTag(api)}${cdnLine}`, // todo replace with better emoji
},
},
],
blocks,
});
}
13 changes: 12 additions & 1 deletion lib/purge.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { publicLogThread } from "./public-logger.js";

const rateLimiter = new RateLimiter(1000, 5);

export async function purge(client, logger, channel, threadTs, deletedBy) {
export async function purge(
client,
logger,
channel,
threadTs,
deletedBy,
{ api = false, reason = "" } = {},
) {
const result = await client.conversations.replies({
channel: channel,
ts: threadTs,
Expand All @@ -20,6 +27,8 @@ export async function purge(client, logger, channel, threadTs, deletedBy) {
threadTs,
messages,
deletedBy,
reason,
api,
}),
publicLogThread(client, {
channel,
Expand Down Expand Up @@ -54,6 +63,8 @@ export async function purge(client, logger, channel, threadTs, deletedBy) {
threadTs,
messages: remaining,
deletedBy,
reason,
api,
});
await rateLimiter.deleteBatch(client, logger, channel, remaining, 5, 2000);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/shortcuts/destroy_thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default {
await hideThread(channel, threadTs);
logger.info(`destroy_thread: thread ${threadTs} hidden in ${channel} by ${uid}`);
} else {
await purge(context.userClient, logger, channel, threadTs, uid);
await purge(context.userClient, logger, channel, threadTs, uid, { reason });
}
destroySucceeded = true;
} finally {
Expand Down
69 changes: 69 additions & 0 deletions lib/web/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { findChannelApiKeyByHash, isChannelApiKeyActive, touchChannelApiKey } fr
import { logDelete, notifyDeletion } from "../logger.js";
import { canManage } from "../perms.js";
import { publicLogDelete } from "../public-logger.js";
import { purge } from "../purge.js";
import { RateLimiter } from "../ratelimiter.js";
import { hashApiKey } from "./apiKeys.js";

Expand Down Expand Up @@ -88,6 +89,13 @@ function messageTimestamps(value) {
return timestamps;
}

function threadTimestamp(value) {
if (typeof value !== "string" || !/^\d{10}\.\d{6}$/.test(value)) {
throw new Error(`${describe(value)} is not a Slack thread ts, like 1699999999.123456.`);
}
return value;
}

function deletionReason(value) {
if (value !== undefined && typeof value !== "string") {
throw new Error("Reason must be a string.");
Expand Down Expand Up @@ -264,6 +272,67 @@ export function createApiRouter({ client, botClient = client }) {
return c.json({ ok: failed.length === 0, channel, deleted, failed });
});

api.post("/threads/delete", async (c) => {
const apiKey = c.get("apiKey");

if (!AUDIT_CONFIGURED) {
return fail(
c,
503,
"audit_unavailable",
"Deletion is disabled because LOG_CHANNEL is not configured.",
);
}

const raw = await readBoundedBody(c);
if (raw === TOO_LARGE) {
return fail(c, 413, "body_too_large", `Keep the request body under 65,536 bytes.`);
}

let body;
try {
body = JSON.parse(raw);
} catch {
return fail(c, 400, "invalid_json", "Send a JSON body.");
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return fail(c, 400, "invalid_body", "Send a JSON object.");
}

const channel = apiKey.channel_id;

let threadTs;
let reason;
try {
threadTs = threadTimestamp(body.thread_ts);
reason = deletionReason(body.reason);
} catch (error) {
return fail(c, 400, "invalid_request", error.message);
}

if (!(await stillAuthorized(client, apiKey))) {
return fail(
c,
403,
"authorization_revoked",
"That API key can no longer manage this channel.",
);
}

await touchChannelApiKey(apiKey.id);

try {
await purge(client, logger, channel, threadTs, apiKey.user_id, { api: true, reason });
} catch (error) {
const slackError = error.data?.error || "purge_failed";
logger.error(`[api] thread purge failed for ${channel}/${threadTs}: ${slackError}`);
return fail(c, 502, "purge_failed", "The thread could not be fully deleted.");
}

logger.info(`[api] key ${apiKey.key_prefix} purged thread ${threadTs} in ${channel}`);
return c.json({ ok: true, channel, thread_ts: threadTs });
});

api.notFound((c) => fail(c, 404, "unknown_endpoint", "No such API endpoint."));

api.onError((error, c) => {
Expand Down