Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.

This commit is contained in:
unknown
2026-05-22 21:46:06 +02:00
parent 12a0035699
commit 2129081599
32 changed files with 3426 additions and 106 deletions

134
docs/API.md Normal file
View File

@@ -0,0 +1,134 @@
# HTTP API Reference
## Endpoints
### GET /api/health
- **Description:** Health check endpoint.
- **Auth:** None
- **Query params:** None
- **Response:** `{"status":"ok"}`
---
### GET /api/content/:cxid
- **Description:** Get content metadata (files, view limits, password status, etc.).
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- **Response formats:**
- `200 OK` — JSON metadata object:
```json
{
"cxid": "string",
"files": [
{
"idx": 0,
"name": "string",
"mime": "string",
"size": 12345,
"render_flags": 0
}
],
"has_password": true,
"max_views": 10,
"current_views": 3,
"allow_download": true,
"created_at": "2024-01-01T00:00:00+00:00"
}
```
- `401 Unauthorized` — Password required but missing or invalid.
- `404 Not Found` — Content does not exist or has been deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- **Notes:**
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response (`Max-Age=3600; SameSite=Strict; HttpOnly; Path=/`).
---
### GET /api/content/:cxid/file/:file_idx
- **Description:** Serve a decrypted file. Supports HTTP Range requests for video/audio streaming. Returns the file with `Content-Disposition: inline` by default, or `attachment` when downloading.
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- `file_idx` — Zero-based file index within the content bundle.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- `download` (optional) — If truthy (`1`, `true`, `yes`), requests a download (`Content-Disposition: attachment`). Ignored if `allow_download` is `false` for the content.
- **Response formats:**
- `200 OK` — File stream with appropriate `Content-Type`.
- `206 Partial Content` — Byte-range response (if `Range` header is present and valid).
- `401 Unauthorized` — Password required but missing or invalid.
- `403 Forbidden` — Download requested but not allowed, or path traversal blocked.
- `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- `416 Range Not Satisfiable` — Invalid `Range` header.
- **Notes:**
- The server increments the view counter on successful full-file responses. Range requests and `If-None-Match` (ETag) matches do **not** increment the counter.
- If the incremented view count reaches `max_views`, the server may delete content files (depending on `keep_content` config) and mark the content as `Deleted`, returning `410 Gone`.
- `Accept-Ranges: bytes` is included for `video/*` and `audio/*` MIME types.
- Cache-Control is `private, max-age=60` for unprotected content and `private, no-store, max-age=0` for password-protected content.
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response.
---
### GET /api/content/:cxid/file/:file_idx/raw
- **Description:** Serve the fully decrypted file as raw plain text (`text/plain; charset=utf-8`). The entire file is decrypted into memory before being returned. No Range support.
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- `file_idx` — Zero-based file index within the content bundle.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- **Response formats:**
- `200 OK` — Plain text body.
- `401 Unauthorized` — Password required but missing or invalid.
- `403 Forbidden` — Path traversal blocked.
- `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- **Notes:**
- The server performs BLAKE3 integrity verification after full decryption.
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response.
---
### POST /api/content/:cxid/verify-password
- **Description:** Explicitly verify a password for password-protected content and receive an authentication cookie.
- **Auth:** None (this is the endpoint used to *obtain* auth).
- **Path params:**
- `cxid` — Content ID string.
- **Body:** JSON object:
```json
{
"password": "string"
}
```
- **Response formats:**
- `204 No Content` — Password is correct. The response includes a `Set-Cookie` header with `cgcx_pw`.
- `401 Unauthorized` — Password is incorrect.
- `404 Not Found` — Content does not exist.
- **Notes:**
- If the content has no password, the endpoint returns `204 No Content` without setting a cookie.
- This endpoint has a separate, stricter rate limit than the general API.
---
## General Behavior
### CORS
The server allows cross-origin requests from its configured `base_url` and common local development origins (`http://127.0.0.1:5173`, `http://localhost:5173`, `http://127.0.0.1:8090`, `http://localhost:8090`).
### Rate Limiting
- General API routes (`/api/health`, `/api/content/...`) share a per-IP rate limit configured by `requests_per_minute` and `burst`.
- `POST /api/content/:cxid/verify-password` has its own rate limit with a burst of 3 and a separate `password_attempts_per_minute` setting.
### Fallback / Static Assets
- `/assets/*` — Serves static files from `frontend/dist/assets`.
- All other non-`/api` paths — Serves `frontend/dist/index.html` (SPA fallback).
- `/api/*` paths with no matching route — Return `404 Not Found` JSON.

194
docs/AUTH_FLOW.md Normal file
View File

@@ -0,0 +1,194 @@
# Authentication Flow
cg.cx uses a simple password-and-cookie authentication model. There is no user account system; access is granted per-content-item by knowing its password.
---
## AuthSource Enum
The server tracks *how* a request was authenticated using the `AuthSource` enum:
```rust
enum AuthSource {
Cookie, // Request presented a valid cgcx_pw cookie
QueryParam, // Request presented a correct ?sc=PASSWORD query param
}
```
This distinction matters because the server only issues a **new** cookie when auth succeeds via `QueryParam`. Cookie-based auth does not re-issue the cookie (the browser already has it).
---
## How Direct Links with `?sc=PASSWORD` Work
1. A client requests a protected endpoint (e.g., `GET /api/content/:cxid` or `GET /api/content/:cxid/file/:file_idx`) with the password in the query string:
```
GET /api/content/abc123?sc=MySecretPassword
```
2. The handler calls `password_from_request(...)`.
3. Inside `password_from_request`, the `sc` query parameter is checked **first**:
- If `sc` is present and the content has a `password_hash`, the server verifies the supplied password against the stored Argon2 hash.
- If verification succeeds, `password_from_request` returns `Some(AuthSource::QueryParam)`.
4. The handler proceeds with the request.
5. On the response path, the handler calls `add_auth_cookie(...)`.
6. Because the auth source is `QueryParam`, `add_auth_cookie` generates a new HMAC-signed `cgcx_pw` cookie and attaches it via a `Set-Cookie` header.
7. The clients browser stores the cookie. Subsequent requests to the same domain automatically include it, so the `?sc=...` parameter is no longer needed.
---
## How the Server Validates Passwords (Argon2)
Password verification happens in two places: `password_from_request` and the `verify_password` endpoint.
### Stored Hash Format
Password hashes are stored in the database as Argon2id hashes in the standard PHC string format (e.g., `$argon2id$v=19$m=65536,t=3,p=4$...`).
### Verification
```rust
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(&hash)?;
let valid = Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
```
- Uses the default Argon2 parameters.
- If parsing the stored hash fails, verification is treated as failed.
- If verification fails, the server returns `401 Unauthorized`.
---
## How Cookies Are Set After Successful Query-Param Auth
After a successful `?sc=...` authentication, the response includes:
```http
Set-Cookie: cgcx_pw=<value>; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/
```
- **Name:** `cgcx_pw`
- **Max-Age:** 3600 seconds (1 hour)
- **SameSite:** `Strict`
- **HttpOnly:** `true` (not accessible to JavaScript)
- **Path:** `/`
The cookie value is generated by `make_cookie_value(cxid, cookie_secret)` (see [Cookie Format](#cookie-format-hmac-signed) below).
The same cookie is also set by the `POST /api/content/:cxid/verify-password` endpoint after a successful JSON password verification.
---
## How Subsequent Requests Use the Cookie
1. The browser automatically sends the `cgcx_pw` cookie on every request to the same origin.
2. When `password_from_request` is called:
- It first checks for `sc` query param auth.
- If that fails or is absent, it scans **all** `Cookie` headers (there may be multiple).
- For each cookie header, it splits on `;` and looks for a part starting with `cgcx_pw=`.
- When found, it calls `verify_cookie(cxid, &part[8..], cookie_secret)`.
- If `verify_cookie` returns `true`, `password_from_request` returns `Some(AuthSource::Cookie)`.
3. Because the auth source is `Cookie`, `add_auth_cookie` does **not** add a new `Set-Cookie` header.
---
## Cookie Format (HMAC-Signed)
The cookie value is a Base64-encoded string of the form:
```
base64(cxid + ":" + hmac_sha256(cxid, secret))
```
### Building the Cookie (`make_cookie_value`)
```rust
fn make_cookie_value(cxid: &str, secret: &[u8]) -> String {
let mac = hmac_cookie(cxid, secret); // HMAC-SHA256 of cxid
let mut raw = Vec::with_capacity(cxid.len() + 1 + mac.len());
raw.extend_from_slice(cxid.as_bytes());
raw.push(b':');
raw.extend_from_slice(&mac);
base64::engine::general_purpose::STANDARD.encode(&raw)
}
```
### Verifying the Cookie (`verify_cookie`)
```rust
fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool {
let decoded = base64_decode(cookie_value)?;
let mut parts = decoded.splitn(2, |&b| b == b':');
let decoded_cxid = std::str::from_utf8(parts.next()?)?;
let mac_bytes = parts.next()?;
if decoded_cxid != cxid {
return false;
}
let expected = hmac_cookie(cxid, secret);
if mac_bytes.len() != expected.len() {
return false;
}
// Constant-time comparison to prevent timing attacks
mac_bytes.ct_eq(&expected).into()
}
```
### Cookie Secret
The HMAC key (`cookie_secret`) is derived from the master encryption key at startup:
```rust
let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec();
```
This means:
- The cookie secret is deterministic for a given master key.
- Cookies are bound to a specific server instance (or cluster sharing the same master key).
- Changing the master key invalidates all existing auth cookies.
### Security Properties
- **Binding:** The HMAC includes the `cxid`, so a cookie for one piece of content cannot be replayed against another.
- **Tamper resistance:** Without the `cookie_secret`, an attacker cannot forge a valid `cgcx_pw` cookie.
- **Timing safety:** Verification uses `subtle::ConstantTimeEq` to avoid leaking information via timing side channels.
---
## Summary Flow Diagram
```
Client Request
┌─────────────────────────────────────────┐
│ Does the content have a password? │
└─────────────────────────────────────────┘
├─ No ──► Proceed (no auth needed)
└─ Yes
┌─────────────────────────────────────────┐
│ Is ?sc=PASSWORD present and correct? │
│ └─ Argon2 verify against stored hash │
└─────────────────────────────────────────┘
├─ Yes ──► AuthSource::QueryParam
│ └─ Set cgcx_pw cookie on response
└─ No
┌─────────────────────────────────────────┐
│ Is cgcx_pw cookie present and valid? │
│ └─ HMAC-SHA256 verify against secret │
└─────────────────────────────────────────┘
├─ Yes ──► AuthSource::Cookie
│ └─ Proceed without setting new cookie
└─ No ──► 401 Unauthorized
```

106
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,106 @@
# Bot Commands
This document lists all commands and callback actions implemented in `crates/cgcx-bot/src/main.rs`.
---
## Admin Commands (Group-only)
All admin commands require the caller to be an **administrator or owner** of the group.
| Command | Args | Description |
|---------|------|-------------|
| `/reload` | none | Reload moderation lists from disk. |
| `/blacklist_uid` | `<ID>` | Blacklist a user by Telegram ID globally and set their role to `banned`. |
| `/whitelist_uid` | `<ID>` | Remove a user from the global blacklist and restore their role to `user`. |
| `/help` | none | Show the admin help message listing all admin commands. |
| `/get_id` | none | Get the current group chat ID. |
| `/get_id` | `<@username>` | Search administrators in this chat by username. |
| `/get_id` | `<displayname>` | Search members in this chat by display name. |
| `/create_submit_forward` | `<dest_chat_id> <review_group_id> [forward_message]` | Create a submission forward link. Bot must be admin in both destination and review groups. |
| `/show_c_forward` | `[page]` | List active forward links for this chat with pagination. |
| `/add_blacklist` | `<user_id>` | Blacklist a user in **all active forwards** for this source chat. |
| `/rm_blacklist` | `<user_id>` | Remove a user from the blacklist in **all active forwards** for this source chat. |
| `/sban` | `@user <dur> <unit> [reason]` | Ban a user for a specified duration. |
| `/smute` | `@user <dur> <unit> [reason]` | Mute a user for a specified duration. |
| `/mute` | `@user [reason]` | Mute a user indefinitely. |
| `/pban` | `@user [reason]` | Permanently ban a user. |
| `/kick` | `@user [reason]` | Kick a user from the group. |
| `/rmute` | `@user` | Revoke an active mute and restore the user's chat permissions. |
| `/rban` | `@user` | Revoke an active ban and unban the user. |
---
## User Commands (DM)
| Command | Args | Description |
|---------|------|-------------|
| `/start` | none | Start the bot. Displays terms if not accepted, otherwise shows the main menu. |
| `/start` | `submitfwdid<code>` | Deep-link entry into **Submission Mode** for a forward. |
| `/cancel` | none | Cancel the current operation and return to the main menu. |
---
## Callback Actions
Callbacks use the format `v1:<namespace>:<action>[:<id>]`.
### Terms
| Callback | Description |
|----------|-------------|
| `v1:terms:accept` | Accept the terms of service. |
| `v1:terms:reject` | Reject the terms of service. |
### Main Menu
| Callback | Description |
|----------|-------------|
| `v1:menu:upload_media` | Enter media upload staging. |
| `v1:menu:upload_doc` | Enter document upload staging. |
| `v1:menu:upload_text` | Enter text upload staging. |
| `v1:menu:prev_uploads` | View previous uploads. |
| `v1:menu:report` | Enter content reporting flow. |
| `v1:menu:main` | Return to main menu. |
### Staging
| Callback | Description |
|----------|-------------|
| `v1:stage:confirm` | Confirm staged items and proceed to upload options. |
| `v1:stage:cancel` | Cancel the upload and return to main menu. |
### Upload Options
| Callback | Description |
|----------|-------------|
| `v1:opt:toggle_destroy` | Cycle auto-destroy max views (Off → 1 → 3 → 5 → 10 → 50 → Off). |
| `v1:opt:toggle_download` | Toggle the "allow download" flag. |
| `v1:opt:set_password` | Prompt user to send a password (or `/skip`). |
| `v1:opt:confirm_final` | Confirm options and finalize the upload. |
| `v1:opt:back` | Go back to upload staging. |
### Previous Uploads
| Callback | Description |
|----------|-------------|
| `v1:prev:page:{page}` | Navigate to a specific page of previous uploads. |
### Submission Mode
| Callback | Description |
|----------|-------------|
| `v1:submit:continue` | Continue into submission upload flow. |
| `v1:submit:exit` | Exit submission mode and return to main menu. |
### Admin / Moderation
| Callback | Description |
|----------|-------------|
| `v1:admin:delcontent:{cxid}` | Delete a content item by its CXID. |
| `v1:admin:delblk:{report_id}` | Delete reported content and blacklist the uploader. |
| `v1:admin:del:{report_id}` | Delete reported content only. |
| `v1:admin:blk:{report_id}` | Blacklist the uploader of reported content only. |
| `v1:admin:ign:{report_id}` | Ignore/dismiss the report. |
### Forward Submissions
| Callback | Description |
|----------|-------------|
| `v1:fwd:approve:{submission_id}` | Approve a forward submission and post it to the destination chat. |
| `v1:fwd:ignore:{submission_id}` | Reject a forward submission. |
| `v1:fwd:blk:{submission_id}` | Blacklist the submitting user from the forward. |
| `v1:fwd:revoke:{forward_id}` | Revoke a forward link. |
| `v1:fwd:page:{page}` | Navigate forward link list pages. |

223
docs/FORWARD_SYSTEM.md Normal file
View File

@@ -0,0 +1,223 @@
# Forward Submission System
This document describes the submission-forward flow that allows users to upload content through the bot for moderator review before it is posted to a destination channel or group.
---
## Database Schema
Defined in `migrations/003_forward_system.sql`.
### `forward_definitions`
```sql
CREATE TABLE forward_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_user_id INTEGER NOT NULL,
source_chat_id INTEGER NOT NULL,
destination_chat_id INTEGER NOT NULL,
review_group_id INTEGER NOT NULL,
forward_message TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL UNIQUE,
share_mode TEXT NOT NULL DEFAULT 'b',
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT datetime('now')
);
```
| Field | Description |
|-------|-------------|
| `id` | Primary key. |
| `creator_user_id` | Telegram ID of the admin who created the forward. |
| `source_chat_id` | The group/chat where `/create_submit_forward` was invoked. |
| `destination_chat_id` | The target channel/group where approved content is posted. |
| `review_group_id` | The moderator group where submissions are sent for review. |
| `forward_message` | Optional template text prepended to approved posts. |
| `code` | Unique 16-character alphanumeric access code. |
| `share_mode` | `'b'` = blacklist mode (default), `'w'` = whitelist mode. |
| `revoked_at` | Timestamp if the forward link was revoked; `NULL` while active. |
**Indexes:** `idx_forward_code`, `idx_forward_source`.
### `forward_submissions`
```sql
CREATE TABLE forward_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
content_id TEXT NOT NULL REFERENCES contents(id),
status TEXT NOT NULL DEFAULT 'pending',
review_message_id INTEGER,
created_at TEXT NOT NULL DEFAULT datetime('now'),
resolved_at TEXT,
resolver_id INTEGER
);
```
| Field | Description |
|-------|-------------|
| `id` | Primary key (submission number). |
| `forward_id` | The forward this submission belongs to. |
| `user_id` | Telegram ID of the submitting user. |
| `content_id` | The uploaded content entry (`contents.id`). |
| `status` | `pending`, `approved`, `ignored`, or `blacklisted`. |
| `review_message_id` | Telegram message ID of the review post in the review group. |
| `resolved_at` | Timestamp when a moderator acted on the submission. |
| `resolver_id` | Telegram ID of the moderator who resolved it. |
**Indexes:** `idx_fwd_sub_forward`, `idx_fwd_sub_user`, `idx_fwd_sub_status`.
### `forward_lists`
```sql
CREATE TABLE forward_lists (
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
list_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT datetime('now'),
PRIMARY KEY (forward_id, user_id, list_type)
);
```
| Field | Description |
|-------|-------------|
| `forward_id` | The forward definition. |
| `user_id` | The affected user. |
| `list_type` | `blacklist` or `allow`. |
This table implements **per-forward** scoped access control.
---
## Creating a Forward (`/create_submit_forward`)
**Usage (group-only, admin-gated):**
```
/create_submit_forward <destination_chat_id> <review_group_id> [forward_message]
```
**Requirements:**
1. Caller must be an **administrator or owner** of the source group.
2. The bot must be an **administrator** in both the `destination_chat_id` and `review_group_id`.
**What happens:**
1. A 16-character alphanumeric `code` is generated (`generate_forward_code`).
2. A row is inserted into `forward_definitions` with:
- `source_chat_id` = current chat
- `share_mode` = `'b'` (blacklist mode)
3. The bot replies with a deep-link URL:
```
https://t.me/<bot_username>?start=submitfwdid<code>
```
---
## Entering Submission Mode
Users click the deep link or send:
```
/start submitfwdid<CODE>
```
**Validation:**
1. The code is looked up in `forward_definitions`.
2. If the forward has been revoked (`revoked_at IS NOT NULL`), the user is told the link is revoked.
3. The scoped access check `ForwardRepo::is_allowed(forward_id, user_id)` is performed:
- The creator is always allowed.
- In **blacklist mode** (`'b'`): allowed unless the user has a `blacklist` entry.
- In **whitelist mode** (`'w'`): allowed only if the user has an `allow` entry.
4. If allowed, the bot enters `BotState::SubmitMode { forward_id, code }` and presents **Continue / Exit** buttons.
---
## Submission Flow
1. **Continue** — The user is transitioned to `BotState::MainMenu { pending_forward_id: Some(forward_id) }`. All uploads staged from this point are tagged with the pending forward ID.
2. **Upload** — The user stages files (media, documents, or text) and confirms options just like a normal upload.
3. **Finalize** — When the user confirms, `finalize_upload`:
- Creates and encrypts the content entry.
- Inserts a row into `forward_submissions` with `status = 'pending'`.
- Posts a review message to the `review_group_id` with inline buttons:
- `[ Approve ]` → callback `v1:fwd:approve:{submission_id}`
- `[ Ignore ]` → callback `v1:fwd:ignore:{submission_id}`
- `[ Blacklist User ]` → callback `v1:fwd:blk:{submission_id}`
- Stores the sent message ID back into `forward_submissions.review_message_id`.
4. **Review** — Moderators in the review group click the buttons to act.
---
## Review Actions
All review callbacks require the clicking user to be an **administrator in the review group** (`is_admin_in_chat`).
### Approve (`v1:fwd:approve`)
1. Generates a random 12-character direct-access password (`generate_direct_password`).
2. Hashes the password with Argon2 and stores it in `contents.password_hash`.
3. Builds the direct link: `{base_url}/?cxid={content_id}&sc={password}`.
4. Posts the link to the destination chat, prefixed with `forward_message` (if set).
5. DM the submitter:
- "Your submission was approved."
- Includes the posted message URL and the direct access link.
6. Edits the review message to show `[ APPROVED ]` and the moderator ID.
7. Sets `forward_submissions.status = 'approved'`.
### Ignore (`v1:fwd:ignore`)
1. DM the submitter: "Your submission was rejected."
2. Edits the review message to show `[ IGNORED ]` and the moderator ID.
3. Sets `forward_submissions.status = 'ignored'`.
### Blacklist User (`v1:fwd:blk`)
1. Adds the submitter to `forward_lists` with `list_type = 'blacklist'` for this forward.
2. Edits the review message to show `[ BLACKLISTED ]` and the moderator ID.
3. Sets `forward_submissions.status = 'blacklisted'`.
4. The user is now blocked from using this forward link again (until removed).
---
## Management Commands
All group-only, admin-gated.
### `/show_c_forward [page]`
Lists forward links created in the current source chat (5 per page).
- Shows code, destination chat ID, review group ID, and status (`Active` or `Revoked`).
- Active forwards include an inline `[ Revoke ]` button.
- Pagination via `<<` / `>>` buttons.
### `/add_blacklist <user_id>`
Iterates all **active** forwards for the current source chat and inserts the user into each forward's `forward_lists` as `blacklist`.
Replies with the count of forwards affected.
### `/rm_blacklist <user_id>`
Iterates all **active** forwards for the current source chat and removes the user from each forward's `forward_lists` where `list_type = 'blacklist'`.
Replies with the count of forwards affected.
---
## Scoped Access Model
Each forward has its own independent access list stored in `forward_lists`.
| `share_mode` | Behavior |
|--------------|----------|
| `'b'` (blacklist) | **Default.** Everyone is allowed unless explicitly blacklisted. |
| `'w'` (whitelist) | Only explicitly allowed users (and the creator) may submit. |
**Note:** The `share_mode` is stored per forward but there is currently no admin command to change it after creation; it defaults to `'b'` at creation time.
---
## Revoking a Forward
Admins or the creator can revoke a forward via the `[ Revoke ]` button on `/show_c_forward`.
- Validates the forward belongs to the current chat.
- Requires creator status **or** admin status.
- Sets `revoked_at = datetime('now')`.
- Revoked forwards reject new submissions immediately.

149
docs/MODERATION.md Normal file
View File

@@ -0,0 +1,149 @@
# Moderation & Punishment System
This document describes the punishment system implemented in the bot.
---
## Database Schema
Defined in `migrations/004_punishments.sql`.
```sql
CREATE TABLE punishments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick'
duration_seconds INTEGER, -- NULL = permanent / indefinite
reason TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT datetime('now'),
revoked_at TEXT,
revoked_by INTEGER,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id);
CREATE INDEX idx_punishments_active ON punishments(active);
```
### Field Reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `INTEGER` | Primary key. |
| `chat_id` | `INTEGER` | The Telegram group/chat where the punishment was applied. |
| `target_user_id` | `INTEGER` | The punished user's Telegram ID. |
| `action_type` | `TEXT` | One of `ban`, `mute`, or `kick`. |
| `duration_seconds` | `INTEGER` | Duration before auto-expiration. `NULL` means **permanent / indefinite**. |
| `reason` | `TEXT` | Optional moderator-provided reason. |
| `created_by` | `INTEGER` | Telegram ID of the moderator who issued the punishment. |
| `created_at` | `TEXT` | ISO timestamp when the punishment was created. |
| `revoked_at` | `TEXT` | ISO timestamp when the punishment was manually or automatically revoked. |
| `revoked_by` | `INTEGER` | Telegram ID of the revoker, or `0` for the system (auto-expiry). |
| `active` | `INTEGER` | `1` if the punishment is still in effect, `0` if revoked. |
---
## Command Reference
All punishment commands require the caller to be an **administrator or owner** of the group.
### Duration-based Punishments
| Command | Syntax | Description |
|---------|--------|-------------|
| `/sban` | `/sban @user <dur> <unit> [reason]` | Ban the user for a specific duration. |
| `/smute` | `/smute @user <dur> <unit> [reason]` | Mute the user for a specific duration. |
### Permanent / Indefinite Punishments
| Command | Syntax | Description |
|---------|--------|-------------|
| `/mute` | `/mute @user [reason]` | Mute the user **indefinitely** (`duration_seconds = NULL`). |
| `/pban` | `/pban @user [reason]` | Permanently ban the user (`duration_seconds = NULL`). |
| `/kick` | `/kick @user [reason]` | Kick the user from the group. Always recorded with `NULL` duration. |
### Revoke Commands
| Command | Syntax | Description |
|---------|--------|-------------|
| `/rmute` | `/rmute @user` | Revoke the active mute for this user and restore chat permissions. |
| `/rban` | `/rban @user` | Revoke the active ban for this user and unban them. |
### Target Resolution
The `@user` argument is resolved in the following order:
1. Numeric user ID.
2. `@username` — matched against chat administrators.
If the target cannot be resolved, the bot replies with *"Could not resolve target user."*
---
## Duration Units Reference
The `parse_duration` function in `crates/cgcx-bot/src/main.rs` accepts the following units (case-insensitive):
| Unit(s) | Seconds | Example |
|---------|---------|---------|
| `s`, `sec`, `secs`, `second`, `seconds` | 1 | `/sban @user 30 s spam` |
| `m`, `min`, `mins`, `minute`, `minutes` | 60 | `/smute @user 10 m offtopic` |
| `h`, `hr`, `hrs`, `hour`, `hours` | 3,600 | `/sban @user 24 h raid` |
| `d`, `day`, `days` | 86,400 | `/sban @user 7 d trolling` |
| `w`, `week`, `weeks` | 604,800 | `/smute @user 2 w` |
| `mo`, `month`, `months` | 2,592,000 (30 days) | `/sban @user 1 mo` |
| `y`, `year`, `years` | 31,536,000 (365 days) | `/pban @user 1 y` |
---
## How Expiration Works (Background Task)
When the bot starts, a background Tokio task is spawned that runs every **60 seconds**:
1. **Query** — Calls `PunishmentRepo::list_expired()`, which selects rows where:
- `active = 1`
- `duration_seconds IS NOT NULL`
- `datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')`
2. **Action per expired punishment**:
- **`ban`** — Calls `unban_chat_member(chat_id, target_user_id)` to lift the ban.
- **`mute`** — Calls `restrict_chat_member` with restored permissions:
- `SEND_MESSAGES`
- `SEND_MEDIA_MESSAGES`
- `SEND_OTHER_MESSAGES`
- `ADD_WEB_PAGE_PREVIEWS`
- **Other types** — No automatic Telegram action is taken.
3. **Record update** — Calls `repo.revoke(p.id, 0)`:
- Sets `active = 0`
- Sets `revoked_at = datetime('now')`
- Sets `revoked_by = 0` (system)
---
## How Revoke Works
### Manual Revoke (`/rmute`, `/rban`)
1. The bot resolves the target user.
2. Queries `get_active_for_chat_target(chat_id, target_user_id, action_type)` to find the active punishment.
3. If found:
- **`/rmute`** — Restores the user's chat permissions via `restrict_chat_member(...)`.
- **`/rban`** — Unbans the user via `unban_chat_member(...)`.
- Calls `repo.revoke(p.id, admin_user_id)` to mark the punishment inactive.
4. If no active punishment is found, the bot replies with *"No active mute/ban found for this user."*
### Revoke via `PunishmentRepo::revoke(id, revoked_by)`
```sql
UPDATE punishments
SET active = 0,
revoked_at = datetime('now'),
revoked_by = ?1
WHERE id = ?2;
```
This is used both for:
- **Automatic expiration** (`revoked_by = 0`)
- **Manual moderator revocation** (`revoked_by = moderator_user_id`)

137
docs/OPERATIONAL_NOTES.md Normal file
View File

@@ -0,0 +1,137 @@
# Operational Notes
This document covers runtime behaviors, limits, and maintenance considerations for operating a cg.cx instance.
---
## Telegram API Rate Limits
The bot does **not** implement explicit request throttling for Telegram API calls. It relies on Teloxide's default behavior and the Telegram Bot API flood-control semantics.
- **Forwarding / posting messages** — Subject to standard Bot API rate limits (roughly ~30 messages/second in groups, lower in smaller chats). Rapid approval of many submissions may trigger `RetryAfter` errors; the bot currently does not back off explicitly.
- **Banning / restricting members** — `banChatMember` and `restrictChatMember` have aggressive per-chat limits. Issuing many punishment commands in quick succession may result in temporary API rejections.
- **Message deletion** — `deleteMessage` is limited to ~300 deletions per chat per 24 hours for bots. The automatic service-message cleanup (see below) contributes to this budget.
**Operational recommendation:** If running in high-traffic groups, monitor bot logs for `RetryAfter` or `429` errors and consider spacing out bulk operations.
---
## System Message Deletion Limits
The bot automatically deletes service messages in groups and channels to reduce noise. In `handle_message_inner`, the following 17 message types are detected and deleted in non-private chats:
- `new_chat_members`
- `left_chat_member`
- `new_chat_title`
- `new_chat_photo`
- `delete_chat_photo`
- `group_chat_created`
- `supergroup_chat_created`
- `channel_chat_created`
- `migrate_to_chat_id`
- `migrate_from_chat_id`
- `pinned_message`
- `video_chat_scheduled`
- `video_chat_started`
- `video_chat_ended`
- `video_chat_participants_invited`
- `message_auto_delete_timer_changed`
- `proximity_alert_triggered`
**Limitations:**
- Some service messages (e.g., `channel_chat_created`) **cannot be deleted by bots** and will silently fail. The code handles this with `let _ = bot.delete_message(...).await;`.
- Deletion failures do not crash the bot or block subsequent message processing.
---
## Storage & Directories
Encrypted content is organized into the following directories (configured in `config/default.toml` under `[storage.paths]`):
| Directory | Purpose |
|-----------|---------|
| `data/media` | Image, video, and audio files (`image/*`, `video/*`, `audio/*`). |
| `data/documents` | All other file types (archives, binaries, etc.). |
| `data/text` | Plain text uploads (`text/*` MIME types). |
| `data/temp` | Temporary files during encryption and upload processing. |
| `data/logs` | Rolling log output from the bot and server. |
**Directory creation:** Both the bot and server call `storage.ensure_dirs().await` at startup, creating missing directories automatically.
---
## Rolling Log Files
Both the bot (`crates/cgcx-bot/src/main.rs`) and the server (`crates/cgcx-server/src/main.rs`) use `tracing-appender` for daily log rotation:
```rust
tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(log_prefix)
.max_log_files(config.logging.max_files)
.build(log_dir)
```
- **Rotation:** Daily.
- **Retention:** `max_files` (default: `7`).
- **Paths:**
- Bot: `data/logs/cgcx-bot.log` (or configured `logging.file_path`)
- Server: `data/logs/cgcx-server.log`
- **Format:** Plain text, ANSI colors disabled for file output.
- **Fallback:** If the rolling appender fails to initialize, the process falls back to console-only logging.
---
## SQLite WAL Mode
Every database connection is opened with:
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
```
**Implications:**
- **WAL (Write-Ahead Logging)** allows readers to proceed without blocking on writers, which is important because the bot and server may share the same SQLite file.
- A `busy_timeout` of 5000 ms reduces "database is locked" errors under concurrent load.
- WAL produces companion files (`db.sqlite-wal`, `db.sqlite-shm`) in the same directory as the database. These are safe to leave in place during normal operation and are automatically checkpointed by SQLite.
---
## Background Task Intervals
| Task | Interval | Description |
|------|----------|-------------|
| **Punishment expiration** | 60 seconds | Bot task that queries `punishments` for expired timed bans/mutes and lifts them. |
| **Orphan cleanup** | 24 hours | Server task that runs `FilePipeline::cleanup_orphans()` to remove files belonging to deleted/blacklisted content (only if `keep_content = false`). |
**Note:** The orphan sweeper skips its first tick on startup to avoid immediate load spikes.
---
## Frontend Chunk Size Warning
The frontend build uses Vite with its default configuration. During `npm run build`, Vite may emit warnings such as:
```
(!) Some chunks are larger than 500 kBs after minification.
```
- This is a **non-blocking** warning; the build completes successfully.
- The warning typically comes from large vendor dependencies (e.g., PDF.js, syntax highlighters).
- No custom `chunkSizeWarningLimit` is configured; the default Vite behavior is accepted.
---
## HTTP Rate Limiting (Server)
The Axum server uses `tower-governor` for per-IP rate limiting:
| Route Group | Config Key | Default | Burst |
|-------------|-----------|---------|-------|
| General API (`/api/health`, `/api/content/...`) | `rate_limiting.requests_per_minute` | 60 | 10 |
| Password verification (`POST /api/content/:cxid/verify-password`) | `rate_limiting.password_attempts_per_minute` | 4 | 3 |
- Exceeding the general limit returns `429 Too Many Requests`.
- The password endpoint has a separate, stricter limit to mitigate brute-force attacks.