Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.
This commit is contained in:
134
docs/API.md
Normal file
134
docs/API.md
Normal 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
194
docs/AUTH_FLOW.md
Normal 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 client’s 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
106
docs/COMMANDS.md
Normal 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
223
docs/FORWARD_SYSTEM.md
Normal 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
149
docs/MODERATION.md
Normal 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
137
docs/OPERATIONAL_NOTES.md
Normal 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.
|
||||
Reference in New Issue
Block a user