diff --git a/AI_CHECKPOINT.md b/AI_CHECKPOINT.md new file mode 100644 index 0000000..f04db58 --- /dev/null +++ b/AI_CHECKPOINT.md @@ -0,0 +1,79 @@ +# AI Checkpoint — cg.cx Refinement Pass + +## Phase +**ALL BATCHES COMPLETE.** Refinement pass finished. No blockers. + +## Final State +- `cargo check --workspace` ✅ passes +- `cargo test --workspace` ✅ passes (0 tests, all crates compile) +- Frontend `npm run build` ✅ passes +- All 10 batches implemented, verified, and merged. + +## Completed Work Summary + +### Batch 1 — Security + Stability +- **B:** `/get_id` extended to channels (`msg.chat.is_channel()`) +- **C:** `/help` HTML parse errors fixed (`` → `[arg]`) +- **E:** `/blacklist_uid` and `/whitelist_uid` restricted to configured admin groups + admins +- **I:** HEAD requests no longer consume auto-destroy views in `serve_file` + +### Batch 2 — Misc Report Section +- Backend: `POST /api/content/:cxid/report` endpoint added to server +- `reqwest` added to `cgcx-server/Cargo.toml` +- Server seeds web-reporter user (id=0) to satisfy FK constraint +- Frontend: Direct report wired to API; hardcoded `harmfulmeowbot` replaced with dynamic `BOT_USERNAME` + +### Batch 3 — Password/Autodestroy + UX +- Homepage password flow fixed: `fetchMetadata` now passes password; 401 handled correctly (`needsPassword = true` or "Incorrect password.") +- Removed redundant `verifyPassword` call from `Home.svelte` + +### Batch 4 — Submission/Review Batching + Hardening +- Existing batching logic verified correct +- **Fixes applied:** + - `serve_raw_file` now increments views (mirrors `serve_file`) + - Approval caption truncated to 1024 chars + - Video/audio sent as native `InputMediaVideo`/`InputMediaAudio` + +### Batch 5 — Review Action Buttons +- Verified: `[ Ban ]`, `[ Blackl. ]`, `[ Ban/BL u. ]` present in review keyboard +- Verified: handlers for `ban`, `blk`, `banblk`, `approve`, `ignore` all work with permission checks + +### Batch 6 — GLOBAL_BAN +- Verified: `GroupsConfig.global_ban` config option present +- Verified: `propagate_punishment` checks flag and propagates to all known chats + +### Batch 7 — Upload Privacy + Metadata +- Verified: `show_author` toggle in upload options +- Verified: metadata bar in `ViewContent.svelte` shows date, size, author hyperlink + +### Batch 8 — Deduplication + Hash Blacklist +- Verified: `plaintext_hash` computed, dedup lookup works, ref_count incremented +- Verified: `HashBlacklistRepo` blocks re-uploads with `BlockedHash` error + +### Batch 9 — Username Tracking +- Verified: `UserRepo::ensure_exists` logs changes to configurable `uname_changes_path` + +### Batch 10 — Homepage Bot Link + Docs +- **Q:** Bot link reordered between Content ID field and "-- cannibal girls --" subtitle +- **Q:** Link color changed to `var(--retro-accent)` (very dark green) +- **P:** `docs/API.md`, `docs/COMMANDS.md`, `docs/MODERATION.md`, `README.md` all updated + +## Files Touched in This Pass +- `crates/cgcx-server/src/main.rs` — view increment, report endpoint, web user seed +- `crates/cgcx-server/Cargo.toml` — reqwest dependency +- `crates/cgcx-bot/src/main.rs` — channel support, help escaping, admin-group gates, media types, caption truncation +- `frontend/src/routes/Home.svelte` — password flow, report wiring, bot link reorder +- `frontend/src/lib/api.js` — API_BASE export +- `docs/API.md`, `docs/COMMANDS.md`, `docs/MODERATION.md`, `README.md` — documentation updates + +## Known Limitations (Not Fixed in This Pass) +1. **Zero test coverage** across the workspace. +2. **Memory usage:** Forward approval/review decrypts entire files into memory (`decrypt_bytes` + `InputFile::memory`). Large files risk OOM. +3. **TOCTOU race:** Concurrent requests to `serve_file` can overserve past `max_views` by 1. +4. **Multi-file view counting:** Each file request increments `view_count`, so multi-file content with low `max_views` may become unavailable before all files are viewed. + +## Blockers +None. + +## Next Step +None — refinement pass is complete. Future work (if any) should start from this checkpoint. diff --git a/AI_MASTER_PLAN.md b/AI_MASTER_PLAN.md new file mode 100644 index 0000000..0f0094e --- /dev/null +++ b/AI_MASTER_PLAN.md @@ -0,0 +1,102 @@ +# AI Master Plan — cg.cx Refinement Pass + +## 1. Current Repo Understanding + +### Architecture +- **10-crate Rust workspace** (`cgcx-core`, `cgcx-config`, `cgcx-crypto`, `cgcx-db`, `cgcx-storage`, `cgcx-content-typing`, `cgcx-file-pipeline`, `cgcx-moderation`, `cgcx-bot`, `cgcx-server`) +- **SQLite** database with WAL mode, 7 migrations already applied (`001_init` through `007_hash_blacklist`) +- **Svelte 5** SPA frontend (`frontend/`), Vite build, served as static files by Axum fallback +- **Teloxide** bot (standalone binary) + **Axum** server (standalone binary), both share DB and config +- Content encrypted with XChaCha20-Poly1305, per-file CEKs wrapped with AES-KW under a master key +- BLAKE3 plaintext hashes stored for deduplication and hash-blacklist enforcement + +### Already Implemented (verified in code) +| Task | Status | Location | +|------|--------|----------| +| D — GLOBAL_BAN config | ✅ | `config/default.toml` `[groups] global_ban`; `propagate_punishment()` in bot | +| F — Approval message with media batches | ✅ | `handle_forward_callback` "approve" — decrypts files, batches up to 10, appends caption to last batch | +| G — Review group media | ✅ | `finalize_upload` — decrypts and sends media batches to review group, appends review text to last batch | +| H — Review buttons (Ban, Blackl., Ban/BL u.) | ✅ | Inline keyboard in `finalize_upload` and handlers in `handle_forward_callback` | +| K — Show/Hide Author toggle | ✅ | `UploadOptions.show_author`, `toggle_author` callback, `show_author` DB column (migration 005) | +| L — File list metadata | ✅ | `ViewContent.svelte` shows `created_at`, `total_size`, `author` (username hyperlink + ID) | +| M — Deduplication | ✅ | `FilePipeline::ingest_file` checks `find_active_by_plaintext_hash`, increments `ref_count` | +| N — Hash blacklist | ✅ | `HashBlacklistRepo`, migration 007, blocked-hash rejection in pipeline | +| O — Username change tracking | ✅ | `UserRepo::ensure_exists` logs changes to `data/uname_changes.json` (configurable path in config) | +| Partial A — Misc report UI | ✅ | `Home.svelte` already has "Report Content via Telegram" link + "Report Content directly" input/button | +| Partial Q — Homepage bot link | ✅ | `Home.svelte` already has `t.me/{BOT_USERNAME}` link, but in wrong DOM order and styling needs tweak | + +### Actual Bugs / Gaps to Fix +| Task | Root Cause | Fix Strategy | +|------|-----------|--------------| +| **B — /get_id** | Admin commands gated behind `msg.chat.is_group() \|\| msg.chat.is_supergroup()`, **excluding channels**. Also, HTML parse errors in logs come from unescaped `` tokens in `/help`, but `/get_id` must also be hardened for channels where `msg.from` / admin visibility differs. | Add `msg.chat.is_channel()` to admin command scope. Ensure `handle_get_id_search` gracefully handles channels (`get_chat_administrators` works in channels too if bot is admin). | +| **C — /help** | `help_text` raw string contains literal ``, `<@username>`, ``, ``, ``, ``. Telegram HTML parse mode rejects unsupported tags like ``. | Escape all argument placeholders: replace `` with `<arg>` or `[arg]`. Minimally invasive: keep the rest of the formatting intact. | +| **E — blacklist_uid / whitelist_uid** | Commands are technically restricted to `admin_group_ids` inside the handler, but the outer command dispatch allows them in any group for admins, producing a confusing "only available in admin group" message. Missing-parameter handling exists but UX is inconsistent with other admin commands. | Move the admin-group check into the command dispatch so non-admin-group chats get a clear "Unauthorized" response. Ensure missing-parameter usage info is returned **before** the admin-group gate when possible, so users in the admin group see usage info immediately. | +| **I — Password + auto-destroy 410** | `serve_file` increments `view_count` for **HEAD requests** because `is_conditional` only checks `If-None-Match`, not request method. Some browsers/proxies/link previews issue HEAD before GET, consuming the view and causing the subsequent GET to hit `view_count >= max_views` → 410. | Add `Method` extractor to `serve_file` and skip view increment when `method == Method::HEAD`. Also skip increment for any non-GET method as defense-in-depth. | +| **J — Password field UX** | `Home.svelte` `submit()` calls `fetchMetadata(cxid)` **without password first**. For password-protected content this returns 401, throws, and the catch block sets a raw JSON error string. `needsPassword` is **never set to true**, so the password field never appears on the home page. Users cannot access password content from the home page at all. | Restructure `submit()`: on 401 from `fetchMetadata`, explicitly check if the error is auth-related and set `needsPassword = true` instead of treating it as a generic error. Ensure password verification only runs on explicit submit/Enter, not on keystrokes. | +| **A — Misc report (complete)** | Frontend UI exists but the "direct" report still opens a Telegram deep link instead of submitting via the web backend. The hardcoded `harmfulmeowbot` username is wrong; should use `BOT_USERNAME` from `api.js`. | Add `POST /api/report` endpoint to the server (it has access to `config.telegram.bot_token`). Server inserts report into DB and forwards a notification to configured review groups via direct Bot API HTTP calls. Update frontend to call the API instead of opening Telegram. | +| **Q — Homepage bot link** | Bot link is present in `Home.svelte` but **DOM order is wrong** (currently above Content ID label; should be between Content ID field and "-- cannibal girls --"). Styling says underline + very dark green/blackish green; current color is `var(--retro-green)` which may need to be `var(--retro-accent)` or a darker custom value. | Reorder elements in `Home.svelte`. Adjust CSS to match spec. Keep `BOT_USERNAME` dynamic import. | + +## 2. Locked Implementation Rules + +- **No broad rewrites.** Fix only the targeted bugs and gaps. +- **No redesign of working flows.** The upload pipeline, encryption, submission forward system, and moderation engine are working — do not touch them except where task I requires the HEAD-request guard. +- **Preserve existing frontend style and behavior.** Keep retro theme, fonts, colors, and animations. Only adjust the specific elements requested in A, J, Q. +- **No cleanup-only changes.** Do not refactor unrelated code, rename variables, or change formatting. +- **4-agent cycle mandatory** for implementation batches. +- **SQLite schema is frozen** except if a new migration is absolutely required. Tasks A–Q do not need new DB tables (report endpoint can reuse existing `reports` table with `reporter_user_id = 0` for web reports). + +## 3. Exact Batch Order for This Refinement Pass + +### Batch 1: Security + Stability (Agent 1) +- **B** — /get_id channel support and robustness +- **C** — /help HTML escaping fix +- **E** — blacklist_uid/whitelist_uid command behavior refinement +- **I** — HEAD request view-count bugfix in `serve_file` + +### Batch 2: Telegram Bot + Permissions (Agent 2) +- Verify Batch 1 bot changes compile and pass basic smoke tests +- **A** backend — Add `POST /api/report` endpoint to server (reuse `reports` table, forward to Telegram review groups via reqwest HTTP call to Bot API) +- **O** verification — Confirm username tracking writes correctly in channels/groups +- **D** verification — Confirm global_ban propagation logic works with recent schema + +### Batch 3: Content Delivery + Rendering (Agent 3) +- **A** frontend — Wire "Report Content directly" to call `POST /api/report` instead of Telegram deep link +- **J** — Fix home page password flow (needsPassword trigger on 401) +- **Q** — Reorder bot link, adjust underline/dark-green styling +- **L** verification — Ensure metadata bar renders correctly after Batch 1 server changes + +### Batch 4: Docs + QA + Regression (Agent 4) +- **P** — Update `docs/COMMANDS.md`, `docs/API.md`, `docs/MODERATION.md`, `README.md` to reflect all changes +- Regression test checklist: /get_id in group + channel, /help output, blacklist_uid usage, password+auto-destroy content loads on first GET, home page password flow, report submission API, bot link styling +- Run `cargo check --workspace` and `cargo test --workspace` +- Run frontend build (`cd frontend && npm run build`) and verify no new warnings + +## 4. Key Risks + +| Risk | Mitigation | +|------|-----------| +| Adding `Method` extractor to `serve_file` changes its signature and could break route registration if not matched exactly. | Use `extract::Method` in the handler signature; Axum `get()` accepts handlers with extra extractors. | +| `POST /api/report` needs to send Telegram messages from the server, which currently has no Telegram client. | Use `reqwest` (already transitive via Axum) to make direct HTTPS POSTs to `https://api.telegram.org/bot/sendMessage`. Add `reqwest` explicitly to `cgcx-server/Cargo.toml`. | +| Channel admin command handling may behave differently because `msg.from` in channels can be the channel itself or anonymous. | Use `msg.sender_chat` or check `msg.from` carefully; `is_admin` still works with the bot's own admin check. | +| Frontend `fetchMetadata` 401 handling change in J must not break non-password flows. | Only set `needsPassword = true` when `err.status === 401`. Keep existing catch behavior for other errors. | + +## 5. Exact 4-Agent Loop + +``` +Batch N assigned to 4 subagents -> execute in parallel -> wait for all 4 -> +brief review -> update AI_CHECKPOINT.md -> continue to Batch N+1 +``` + +Agent definitions (always use these exact names): +1. **Security + Stability** — Server-side hardening, HTML escaping, view-count logic, command permission gates +2. **Telegram Bot + Permissions** — Bot command behavior, channel support, global_ban, backend API wiring +3. **Content Delivery + Rendering** — Frontend UX, Svelte components, API integration, styling +4. **Docs + QA + Regression** — Documentation updates, end-to-end verification, build checks, regression checklist + +## 6. Resume Rules + +- On any resume, **read this file first**, then `AI_CHECKPOINT.md`, then `AI_RESUME_PROMPT.md`. +- Do not skip batches. Do not merge batches. +- If a batch fails review, re-run that batch before advancing. +- Update `AI_CHECKPOINT.md` after every batch completes. +- Preserve all existing working behavior not explicitly listed for change. diff --git a/AI_RESUME_PROMPT.md b/AI_RESUME_PROMPT.md new file mode 100644 index 0000000..50c4626 --- /dev/null +++ b/AI_RESUME_PROMPT.md @@ -0,0 +1,35 @@ +# AI Resume Prompt — cg.cx Refinement Pass + +## Mandatory First Steps on Every Resume +1. **Read `AI_MASTER_PLAN.md`** in full. +2. **Read `AI_CHECKPOINT.md`** in full. +3. **Read `AI_RESUME_PROMPT.md`** (this file) in full. +4. Continue from the exact batch listed in `AI_CHECKPOINT.md`. + +## Execution Rules +- Follow the **exact batch order** in `AI_MASTER_PLAN.md`. +- Use the **mandatory 4-agent structure** for every batch: + 1. Security + Stability + 2. Telegram Bot + Permissions + 3. Content Delivery + Rendering + 4. Docs + QA + Regression +- Run the 4 subagents in **parallel**, wait for all to complete, then do a brief review. +- After review, **update `AI_CHECKPOINT.md`** with: + - Completed tasks from the batch + - Any blockers or deviations + - Next batch to run +- **Do NOT skip batches.** +- **Do NOT merge batches.** +- **Do NOT do broad rewrites, redesigns, or cleanup-only changes.** + +## Context Reminders +- This is a **refinement pass** on an existing, largely working Rust Telegram bot + web codebase. +- Many features (D, F, G, H, K, L, M, N, O, partial A/Q) are already implemented; verify they still work after each batch. +- The frontend is a Svelte 5 SPA with a retro theme. Preserve its style and behavior. +- SQLite is the database; do not add migrations unless absolutely necessary. +- The bot and server are separate binaries sharing the same DB and config. + +## If Blocked +- If a subagent reports a blocker, pause the cycle. +- Update `AI_CHECKPOINT.md` with the blocker details. +- Do not proceed to the next batch until the blocker is resolved. diff --git a/Cargo.lock b/Cargo.lock index 25b1781..18ddc78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -320,6 +326,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cgcx-bot" version = "0.1.0" @@ -444,7 +456,7 @@ version = "0.1.0" dependencies = [ "argon2", "axum", - "base64", + "base64 0.21.7", "blake3", "cgcx-config", "cgcx-content-typing", @@ -458,6 +470,7 @@ dependencies = [ "hex", "hmac", "password-hash", + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -1031,8 +1044,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1289,6 +1304,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1310,13 +1342,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http 1.4.0", "http-body 1.0.1", "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.3", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1619,6 +1659,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -2020,6 +2066,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2162,7 +2263,7 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -2199,13 +2300,65 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64", + "base64 0.21.7", "bitflags 2.11.1", "serde", "serde_derive", @@ -2246,6 +2399,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2268,13 +2427,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2597,6 +2791,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2685,7 +2882,7 @@ dependencies = [ "once_cell", "pin-project", "rc-box", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_with", @@ -2822,6 +3019,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -2859,6 +3071,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2962,9 +3184,11 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -3112,6 +3336,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3325,6 +3555,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3757,6 +3996,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/README.md b/README.md index a4211f1..2689c09 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,13 @@ cg.cx lets Telegram users upload media, documents, or plain text and receive a s | **Auto-Destruct** | Uploaders can set a max view count; content self-destructs once the limit is reached. | | **Password Protection** | Optional per-content passwords with Argon2id-hashed verification and HMAC-SHA256 session cookies. | | **Admin Moderation** | Blacklist / whitelist user IDs, delete content, review reports via Telegram admin groups. | -| **Reporting** | Users can report content via the homepage Misc section or the Telegram bot; reports are routed to review groups with inline admin actions. | +| **Reporting** | Users can report content directly via the web (`POST /api/content/:cxid/report`) or through the Telegram bot; reports are routed to review groups with inline admin actions (delete, blacklist, ban, ignore). | | **Author Visibility** | Uploaders can toggle whether their Telegram username/ID is shown on the share page. | | **Username Tracking** | Username changes are logged to a JSON file for audit and moderation purposes. | | **Global Ban Config** | Optional `global_ban` flag propagates punishments across all configured admin groups, review groups, and active forward chats. | | **Content Deduplication** | BLAKE3 plaintext hashing enables automatic reuse of existing encrypted files when identical content is re-uploaded. | | **Hash Blacklist** | Moderators can block re-uploads of known-banned content by its plaintext hash at ingestion time. | +| **Native Media Batching** | Review and forward batches use native Telegram photo, video, audio, and document types with automatic caption truncation to 1,024 chars. | | **Streaming Decryption** | Large encrypted files are decrypted and streamed chunk-by-chunk without loading into memory. | | **Content Typing & Safety** | Automatic MIME detection and render flags flag dangerous/executable files for safe handling. | @@ -131,6 +132,7 @@ cg.cx is organized as a **Rust workspace** with 10 focused crates. This modular | **Backend** | Rust (edition 2021), Tokio async runtime | | **Web Server** | Axum 0.7, Tower HTTP middleware | | **Telegram Bot** | Teloxide 0.13 | +| **HTTP Client** | reqwest 0.12 (server-side report forwarding) | | **Frontend** | Svelte 5, Vite 5 | | **Database** | SQLite 3 (WAL mode), `rusqlite` + `rusqlite_migration` | | **Cryptography** | libsodium (via `sodiumoxide`), `aes-kw`, `blake3`, `argon2`, `hmac`, `sha2` | @@ -233,11 +235,13 @@ cargo run -p cgcx-server The server binds to `127.0.0.1:8080` by default and serves: -- `/` - Svelte frontend +- `/` - Svelte frontend (includes dynamic bot username link and direct web reporting) - `/api/health` - health check - `/api/content/:cxid` - metadata JSON - `/api/content/:cxid/verify-password` - password verification - `/api/content/:cxid/file/:file_idx` - streamed decrypted file +- `/api/content/:cxid/file/:file_idx/raw` - streamed decrypted file (raw text) +- `/api/content/:cxid/report` - submit a direct web report - `/assets/*` - static frontend assets ### Run the Telegram Bot @@ -369,20 +373,54 @@ Use Let's Encrypt (certbot) or a managed TLS terminator. The `__Host-pw` cookie Admin commands are restricted to users in configured `admin_group_ids` who also have the `admin` role in the database. -| Command | Usage | Description | -| ---------------- | -------------------------- | ---------------------------------------------------------------------------------------------- | -| `/reload` | `/reload` | Reloads moderation lists from disk (`data/blacklisted_ids.json`, `data/whitelisted_ids.json`). | -| `/blacklist_uid` | `/blacklist_uid ` | Blacklists a Telegram user ID globally and sets their role to `banned`. Shows usage info if the ID is missing. | -| `/whitelist_uid` | `/whitelist_uid ` | Removes a user from the global blacklist and restores their role to `user`. Shows usage info if the ID is missing. | +| Command | Usage | Description | +| ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `/reload` | `/reload` | Reloads moderation lists from disk (`data/blacklisted_ids.json`, `data/whitelisted_ids.json`). | +| `/blacklist_uid` | `/blacklist_uid ` | Blacklists a Telegram user ID globally and sets their role to `banned`. **Restricted to configured admin groups.** Shows usage info if the ID is missing. | +| `/whitelist_uid` | `/whitelist_uid ` | Removes a user from the global blacklist and restores their role to `user`. **Restricted to configured admin groups.** Shows usage info if the ID is missing. | +| `/help` | `/help` | Shows all admin commands with usage info. Uses HTML parse mode with proper escaping. | +| `/get_id` | `/get_id` | Returns the current chat ID. Works in groups, supergroups, and channels (admin-only). | +| `/get_id` | `/get_id @username` | Searches administrators by username (admin-only). | +| `/get_id` | `/get_id displayname` | Searches members in this chat by display name (admin-only). | +| `/create_submit_forward` | `/create_submit_forward [msg]` | Creates a submission forward link. Bot must be admin in both destination and review groups. | +| `/show_c_forward` | `/show_c_forward [page]` | Lists active forward links with revoke buttons. | +| `/add_blacklist` | `/add_blacklist ` | Blacklists a user in all active forwards for this chat. | +| `/rm_blacklist` | `/rm_blacklist ` | Removes a user from blacklist in all active forwards for this chat. | +| `/sban` | `/sban @user [reason]` | Bans a user for a duration (e.g., `1 h`, `3 d`). Propagates if `global_ban` is enabled. | +| `/smute` | `/smute @user [reason]` | Mutes a user for a duration. Propagates if `global_ban` is enabled. | +| `/mute` | `/mute @user [reason]` | Mutes a user indefinitely. Propagates if `global_ban` is enabled. | +| `/pban` | `/pban @user [reason]` | Permanently bans a user. Propagates if `global_ban` is enabled. | +| `/kick` | `/kick @user [reason]` | Kicks a user from the group. Propagates if `global_ban` is enabled. | +| `/rmute` | `/rmute @user` | Revokes an active mute. | +| `/rban` | `/rban @user` | Revokes an active ban. | ### Review Groups -Reports submitted by users are forwarded to all configured `review_group_ids` with an inline keyboard: +Reports submitted via the Telegram bot or the web frontend are forwarded to all configured `review_group_ids` with an inline keyboard: -- **🗑⛔ Rmv + Ban** - Deletes the reported content and blacklists the uploader. -- **🗑 Delete Only** - Deletes the reported content. -- **⛔ Blacklist Only** - Blacklists the uploader and sets their role to `banned`. -- **📝 Ignore** - Dismisses the report. +- **[ Rmv + Ban ]** - Deletes the reported content and blacklists the uploader. +- **[ Delete Only ]** - Deletes the reported content. +- **[ Blacklist Only ]** - Blacklists the uploader and sets their role to `banned`. +- **[ Ignore ]** - Dismisses the report. + +Web reports are submitted via `POST /api/content/:cxid/report` and include a default reason of `"Direct web report"`. The server forwards the report to all review groups using the Telegram Bot API directly. + +### Submission Forward System + +The bot supports a submission-forward workflow for moderated content distribution: + +1. Admins create a forward definition with `/create_submit_forward [message]`. +2. Users submit content through a private-link start parameter (`?start=submitfwdid`). +3. Submissions are sent to the review group with action buttons: **Approve**, **Ignore**, **Blacklist**, **Ban**, **Ban+Blacklist**. +4. On approval, decrypted media is batched and forwarded to the destination chat. +5. Media is correctly typed as photo, video, audio, or document; captions are truncated to Telegram's 1,024-character limit. +6. The submitter receives a DM with the posted link. + +### Auto-Destruct & Password Protection Fixes + +- **HEAD request safety**: `HEAD` requests to content endpoints no longer consume a view count. Range requests and conditional (`If-None-Match`) requests also skip the increment. +- **serve_raw_file parity**: The raw text endpoint (`/api/content/:cxid/file/:file_idx/raw`) now mirrors `serve_file` view-increment behavior, including the 30-second delayed cleanup when `max_views` is reached. +- **Homepage password UX**: The frontend now shows a password field when the server returns `401`, and displays `"Incorrect password."` on verification failure. ### Moderation Modes diff --git a/agent1_batch10.md b/agent1_batch10.md new file mode 100644 index 0000000..53703a1 --- /dev/null +++ b/agent1_batch10.md @@ -0,0 +1,29 @@ +# Batch 10 — API Documentation Update + +## File Changed +- `docs/API.md` + +## Exact Changes Made + +### 1. Added `POST /api/content/:cxid/report` endpoint +Inserted a new endpoint section between `GET /api/content/:cxid/file/:file_idx/raw` and `POST /api/content/:cxid/verify-password`. + +- **Auth:** None +- **Body:** `{ "reason": "string" }` +- **Behavior documented:** Validates cxid, checks content is active, inserts report with `reporter_user_id = 0` (web), forwards notification to all `review_group_ids` via Telegram Bot API +- **Response:** `204 No Content` on success, `404 Not Found` if content not found/deleted/blacklisted +- **Rate limiting:** Covered by the general API governor + +### 2. Updated `GET /api/content/:cxid/file/:file_idx` view counter note +Changed the note from: +> "Range requests and `If-None-Match` (ETag) matches do **not** increment the counter." + +To: +> "Range requests, `If-None-Match` (ETag) matches, and HEAD requests do **not** increment the counter." + +### 3. Added "Password Flow" subsection under General Behavior +Inserted after "Rate Limiting" and before "Fallback / Static Assets" to clarify: +- The `sc` query parameter is checked on both the metadata endpoint (`GET /api/content/:cxid`) and the file endpoints (`GET /api/content/:cxid/file/:file_idx`, `GET /api/content/:cxid/file/:file_idx/raw`). +- When valid, the server sets an HMAC-signed `cgcx_pw` cookie on the response. +- Passwords can also be provided via the `cgcx_pw` cookie. +- For programmatic verification, use `POST /api/content/:cxid/verify-password`. diff --git a/agent1_batch2.md b/agent1_batch2.md new file mode 100644 index 0000000..0c8126c --- /dev/null +++ b/agent1_batch2.md @@ -0,0 +1,64 @@ +# Agent 1 — Batch 2 Implementation Report + +## Task +Implement `POST /api/content/:cxid/report` in `crates/cgcx-server/src/main.rs`. + +## Changes Made + +### 1. `crates/cgcx-server/Cargo.toml` +- Added dependency: + ```toml + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + ``` + +### 2. `crates/cgcx-server/src/main.rs` +- **Imports**: Added `ReportRepo` to the `cgcx_db` import list. +- **AppState**: Added `http_client: reqwest::Client` field. +- **Main initialization**: Created `reqwest::Client::new()` and included it in `AppState`. +- **Route**: Added `.route("/api/content/:cxid/report", post(report_content))` to the main router. + - The route is automatically covered by the existing `tower_governor::GovernorLayer` applied to the whole app. +- **Structs**: + - Added `ReportRequest { reason: String }` for JSON deserialization. +- **Handler `report_content`**: + 1. Parses `cxid` from path parameter. + 2. Looks up content via `ContentRepo::get`; returns `404 Not Found` if missing. + 3. Validates content is active (not `Deleted` or `Blacklisted`); returns `404` otherwise. + 4. Counts associated files via `ContentFileRepo::list_by_content`. + 5. Inserts a report row via `ReportRepo::insert` with `reporter_user_id = 0`. + 6. Constructs an HTML notification message matching the bot’s report format: + ``` + [ NEW REPORT ] #{report_id} + + CXID: {cxid} + Reporter: web + Owner: {content.user_id} + Uploaded: {content.created_at} + Files: {file_count} + ``` + 7. Sends the message to **all** configured `review_group_ids` via direct HTTPS `POST` to the Telegram Bot API (`{api_base}/bot{token}/sendMessage`). + 8. Includes an inline keyboard with the same admin actions as bot reports: + - `[ Rmv + Ban ]` → `v1:admin:delblk:{report_id}` + - `[ Delete Only ]` → `v1:admin:del:{report_id}` + - `[ Blacklist Only ]` → `v1:admin:blk:{report_id}` + - `[ Ignore ]` → `v1:admin:ign:{report_id}` + 9. If any HTTPS call fails, logs a `tracing::warn` but **does not** fail the HTTP request. + 10. Returns `204 No Content` on success. + +## Validation + +- `cargo check -p cgcx-server` — **PASSED** (clean compile, no warnings). +- The router merge order places the new route inside the same `Router` that gets the general governor layer, so rate limiting applies automatically. + +## Open Risks / Notes + +1. **Foreign Key for `reporter_user_id = 0`**: The `reports` table has `FOREIGN KEY (reporter_user_id) REFERENCES users(id)`. If SQLite `PRAGMA foreign_keys = ON` is active and no user with `id = 0` exists, the insert will fail with a database error (returned as `500 Internal Server Error`). This matches the explicit instruction to use `0` for web submissions, but the project may need a dummy user row or a schema adjustment if this becomes an issue in production. +2. **Telegram API failures are non-blocking**: As required, a failed notification is only logged; the caller still receives `204`. This means review groups could miss reports if the network or Telegram API is down. +3. **No dedicated rate limit for reports**: The endpoint shares the general API rate-limit bucket. If high report volume is expected, a separate governor config (like the password route) could be considered later. + +## Recommended Next Step + +- Verify the frontend report submission flow end-to-end against the new endpoint. +- Optionally seed a user with `id = 0` (or relax the FK) if web reports trigger foreign-key violations. + +--- +**Date**: 2026-05-24 diff --git a/agent1_batch3.md b/agent1_batch3.md new file mode 100644 index 0000000..10fcda5 --- /dev/null +++ b/agent1_batch3.md @@ -0,0 +1,61 @@ +# Server-Side Password Authentication Flow Assessment (Batch 3) + +**File inspected:** `crates/cgcx-server/src/main.rs` + +## Flow Verification + +### 1. `get_metadata` handler (`/api/content/:cxid`) +- **Lines ~496–568** +- Sequence: + 1. Content status check (Deleted/Blacklisted) → **404 NotFound** + 2. Max-views check (`content.view_count >= max`) → **410 Gone** + 3. Password validation via `password_from_request(&headers, query.sc.as_deref(), ...)` → **401 Unauthorized** if invalid + 4. Returns metadata JSON if all checks pass +- **View increment:** None. Metadata requests do not consume auto-destroy views. +- **Conclusion:** Password check is enforced and returns 401 on failure. ✅ + +### 2. `serve_file` handler (`/api/content/:cxid/file/:file_idx`) +- **Lines ~700–890** +- Sequence: + 1. Content status check → **404** + 2. Max-views check → **410** + 3. Password validation via `password_from_request` → **401** if invalid + 4. Download permission check → **403 Forbidden** if `?download=true` but not allowed + 5. File lookup, path-traversal validation, ETag conditional check (304) + 6. Range header parsing + 7. **View increment at line ~825:** `repo.increment_views(&content_id).await?` + 8. If `new_views >= max`, spawns background auto-delete task after 30s + 9. Streams decrypted file body +- **Conclusion:** View increment happens **after** password validation and all other guards. Unauthorized requests cannot consume views. ✅ + +### 3. `password_from_request` helper +- **Lines ~436–475** +- Validates `sc` query parameter using Argon2 (`PasswordHash::new` + `Argon2::default().verify_password`). +- Falls back to `cgcx_pw` cookie verified with HMAC-SHA256 and `subtle::ConstantTimeEq`. +- Returns `None` on any mismatch, causing the caller to return 401. +- **Conclusion:** Argon2 verification is present and constant-time. ✅ + +### 4. `serve_raw_file` handler (`/api/content/:cxid/file/:file_idx/raw`) +- **Lines ~920–1020** +- Sequence mirrors `serve_file` for status, max-views, and password checks. +- **View increment:** None. This endpoint never increments `view_count`. + +--- + +## Edge Cases & Concerns + +| # | Edge Case | Impact | Risk | +|---|-----------|--------|------| +| 1 | **`serve_raw_file` skips view increment** | Requests to the `/raw` endpoint do not consume auto-destroy views. A user could repeatedly preview raw text without triggering deletion. | Medium — bypasses view-count enforcement for text content previews. | +| 2 | **Zero-size files in `serve_file` return early** | The zero-size branch (line ~751) returns a response **before** the increment block, so zero-byte files never consume a view. | Low — niche, but technically bypasses max-views for empty files. | +| 3 | **Range/conditional/HEAD requests skip increment** | `serve_file` only increments when `!is_range && !is_conditional && !is_head`. Video/audio seeking (range requests), cache revalidation (If-None-Match), and HEAD probes do not count as views. | Low — intentional for UX, but means views are under-counted. | +| 4 | **Max-views checked before password validation** | In all three handlers, if `view_count >= max`, a request with a wrong password receives **410 Gone** instead of **401 Unauthorized**. This leaks that the content existed and exhausted its views without requiring the password. | Low — information disclosure about content lifecycle. | +| 5 | **TOCTOU race on view increment** | `content.view_count` is read in `repo.get()` and incremented later in a separate statement. Under concurrent requests, two clients could both pass the initial `view_count < max` check and both increment, causing `view_count` to exceed `max`. Both requests are still served. | Low — SQLite serializes writes via `conn.lock()`, but the read-write gap still allows one extra view. | + +--- + +## Conclusion + +- **Password authentication flow is correct.** Wrong passwords receive 401 and do **not** increment view counts. +- **No server changes are required for Batch 3.** The frontend password fix (if any) is independent of server behavior. +- The identified edge cases are pre-existing behaviors. None are blockers for Batch 3, but items 1 (`serve_raw_file` bypass) and 5 (TOCTOU race) may be worth addressing in a future hardening pass if auto-destroy accuracy is critical. diff --git a/agent1_batch4.md b/agent1_batch4.md new file mode 100644 index 0000000..2775e8a --- /dev/null +++ b/agent1_batch4.md @@ -0,0 +1,103 @@ +# Batch 4 Security + Stability Verification Findings + +**Date:** 2026-05-24 +**File:** `crates/cgcx-server/src/main.rs` +**Lines analyzed:** 1–1346 + +--- + +## 1. `get_metadata` and `serve_file` — Forward-submitted content edge cases + +**Status:** No forward-specific breakage, but a functional gap exists. + +Forward submissions are stored in the same `contents` / `content_files` tables as regular uploads. The web server has no forward-specific endpoints or logic (the forward system is handled entirely by the Telegram bot layer). Therefore: + +- `get_metadata` (line ~565) and `serve_file` (line ~640) treat forward-submitted content identically to direct uploads. +- Status checks (`Deleted` / `Blacklisted`) and `max_views` pre-checks work the same for all content. + +**Finding:** `get_metadata` does **not** increment views. This is correct for metadata alone, but it does mean a client can call `get_metadata` unlimited times on auto-destroy content without consuming the view budget. Combined with the `serve_raw_file` bypass (see §4), a user can enumerate metadata and download raw files without ever triggering auto-destroy. + +**File/line:** `main.rs:565–625` (`get_metadata`), `main.rs:640–920` (`serve_file`) + +--- + +## 2. `serve_file` view-increment logic — Concurrent requests for multi-file content + +**Status:** Two issues found. + +### 2a. Per-file view consumption breaks multi-file auto-destroy content +`serve_file` increments `view_count` once **per file request** (`main.rs:825`). If a content item contains 3 files, viewing all 3 files consumes 3 views. For content with `max_views = 1`, only the first file request succeeds before the content is marked for deletion. This means multi-file auto-destroy content is effectively unusable — users cannot view all files before the content self-destructs. + +**File/line:** `main.rs:825` (`repo.increment_views(&content_id)`) + +### 2b. TOCTOU race condition allows overserving past `max_views` +The flow in `serve_file` is: +1. Read `content.view_count` from `repo.get()` (stale snapshot). +2. Check `content.view_count >= max` → early `GONE` if true. +3. Later, if not HEAD/range/conditional, call `repo.increment_views()` atomically. +4. Check `new_views >= max` → trigger cleanup, **but still stream the file**. + +Because there is no lock between the pre-check and the increment, two (or more) concurrent requests can both pass step 2, both increment, and both be served even if the total exceeds `max_views`. The DB increment is atomic, but the **serve decision** is not gated on the increment result. + +Example with `max_views = 1`: +- Request A: pre-check passes (view_count = 0) +- Request B: pre-check passes (view_count = 0) +- Request A: `increment_views` → 1, triggers cleanup, streams file +- Request B: `increment_views` → 2, triggers cleanup, streams file + +Both requests are served; the content is overserved. + +**File/line:** `main.rs:778–790` (pre-check), `main.rs:825–850` (increment + post-check) + +--- + +## 3. Password + auto-destroy interaction + +**Status:** Partially fixed, but one bypass remains. + +### What is correct: +- **HEAD requests:** `is_head = method == Method::HEAD` skips the increment block (`main.rs:824`). +- **Conditional requests:** `is_conditional = headers.contains_key(IF_NONE_MATCH)` skips the increment block (`main.rs:823`). +- **Unauthorized requests:** Password check happens **before** any view increment. An unauthorized request returns `401 Unauthorized` without consuming a view (`main.rs:799–808`). + +### What is broken: +- **`serve_raw_file` does not increment views at all** (see §4). An attacker without a password can still be blocked by the auth check, but an **authorized** user can view password-protected auto-destroy content unlimited times via `/raw` without consuming views. The password gate works, but the view counter does not. + +**File/line:** `main.rs:799–808` (auth before increment), `main.rs:820–826` (HEAD/conditional skip), `main.rs:923–1013` (`serve_raw_file` missing increment) + +--- + +## 4. `serve_raw_file` — Missing view increment + +**Status:** Confirmed bypass. **Not intentional.** + +`serve_raw_file` (`main.rs:923`): +- Checks status and `max_views` (`main.rs:935–941`). +- Checks password (`main.rs:943–954`). +- Streams the fully decrypted file (`main.rs:995–1013`). +- **Never calls `repo.increment_views()`**. + +This means: +1. **Auto-destroy bypass:** Any client (authorized or not, depending on password) can download the raw file unlimited times without the view counter ever increasing. Content with `max_views = 1` will never auto-destroy if accessed through `/raw`. +2. **Inconsistency:** The regular `serve_file` endpoint consumes views, but `/raw` does not. This creates an obvious bypass path. + +**File/line:** `main.rs:923–1013` (`serve_raw_file`) + +--- + +## Recommended Fixes + +| # | Issue | Recommendation | +|---|-------|----------------| +| 1 | `serve_raw_file` view bypass | Add the same view-increment logic to `serve_raw_file` that exists in `serve_file`, including the HEAD / conditional / range skips. | +| 2 | Multi-file over-increment | Consider incrementing views in `get_metadata` (or adding a separate "content view" session token) so that one metadata fetch + N file fetches counts as 1 view, not N+1. Alternatively, document that each file request = 1 view. | +| 3 | TOCTOU race on `max_views` | Move the serve/deny decision into the DB transaction. Use `UPDATE … RETURNING` to atomically increment and read the new view count, and if the new count exceeds `max`, abort the transaction and return `GONE` **before** streaming. Alternatively, accept the soft limit behavior but add a `CHECK` constraint or second gate after increment that returns `GONE` if `new_views > max`. | + +--- + +## Summary + +- **§1:** Forward content is handled safely; no forward-specific edge cases in the web handlers. +- **§2:** `serve_file` has a race condition that allows concurrent requests to overserve past `max_views`. Multi-file content also consumes multiple views (one per file). +- **§3:** HEAD, conditional, and unauthorized requests correctly avoid consuming views. +- **§4:** **`serve_raw_file` is a confirmed bypass** — it never increments views, allowing unlimited access to auto-destroy content. diff --git a/agent1_batch5_9.md b/agent1_batch5_9.md new file mode 100644 index 0000000..64a845e --- /dev/null +++ b/agent1_batch5_9.md @@ -0,0 +1,46 @@ +# Verification Report: Server-Side Features (Batches 5–9) + +## 1. Deduplication + Hash Blacklist (Batch 8) +**File:** `crates/cgcx-file-pipeline/src/lib.rs` + +| Check | Status | Line(s) | Notes | +|-------|--------|---------|-------| +| `plaintext_hash` computed during encryption | **PASS** | ~69, ~89, ~95 | `blake3::Hasher` updated in the encryption loop; finalized after the stream is complete. | +| `find_active_by_plaintext_hash` checked before storing new files | **PASS** | ~103 | Deduplication lookup occurs after blacklist check and before persisting to disk. | +| `HashBlacklistRepo::contains` checked and returns `BlockedHash` | **PASS** | ~99–101 | Blacklist is enforced *before* deduplication. On match, temp file is dropped and `CgcxError::BlockedHash` is returned. | + +**No issues found.** The order (blacklist → dedup → persist) is correct and safe. + +--- + +## 2. View-Count / Raw-File Fix (Batch 4 Follow-Up) +**File:** `crates/cgcx-server/src/main.rs` + +| Check | Status | Line(s) | Notes | +|-------|--------|---------|-------| +| `serve_raw_file` increments views correctly | **PASS** | ~996 | Diff confirms `method: Method` parameter was added; view increment is guarded by `if !is_head` (same pattern as `serve_file` at line ~825). | + +**No issues found.** `serve_raw_file` now mirrors `serve_file`’s view-counting behavior, including the 30-second delayed cleanup when `max_views` is reached. + +--- + +## 3. Metadata / Author Visibility (Batch 7) +**File:** `crates/cgcx-server/src/main.rs` + +| Check | Status | Line(s) | Notes | +|-------|--------|---------|-------| +| `get_metadata` returns `author` only when `show_author` is true | **PASS** | ~530–543 | `author` is set to `Some(AuthorInfo { ... })` only when `content.show_author` is `true`; otherwise `None`. | + +**No issues found.** The `ContentMetadata` struct also includes the new `total_size` field (line ~556). + +--- + +## Summary + +| Feature | Result | +|---------|--------| +| Deduplication + Hash Blacklist | ✅ PASS | +| View-Count / Raw-File Fix | ✅ PASS | +| Metadata / Author Visibility | ✅ PASS | + +No blocking issues or deviations from the approved design were identified. diff --git a/agent1_security_stability.md b/agent1_security_stability.md new file mode 100644 index 0000000..6ab1d2c --- /dev/null +++ b/agent1_security_stability.md @@ -0,0 +1,58 @@ +# Agent 1: Security & Stability Findings + +## Task I: Fix serve_file HEAD-request view-count leak + +### Problem +The `serve_file` handler in `crates/cgcx-server/src/main.rs` incremented the `view_count` on every request, including HEAD requests. This caused password-protected or auto-destroy content to return `410 Gone` on the first real GET because a prior HEAD request (e.g., from a link preview or browser prefetch) had already consumed the single allowed view. + +### Changes Made +**File:** `crates/cgcx-server/src/main.rs` + +1. **Added `method: Method` extractor to the handler signature:** + ```rust + // Before: + async fn serve_file( + State(state): State, + Path((cxid, file_idx)): Path<(String, u32)>, + Query(query): Query, + headers: HeaderMap, + ) -> AppResult { + + // After: + async fn serve_file( + State(state): State, + Path((cxid, file_idx)): Path<(String, u32)>, + Query(query): Query, + headers: HeaderMap, + method: Method, + ) -> AppResult { + ``` + +2. **Skipped view-count increment for HEAD requests:** + ```rust + // Before: + let is_range = range.is_some(); + let is_conditional = headers.contains_key(header::IF_NONE_MATCH); + if !is_range && !is_conditional { + let new_views = repo.increment_views(&content_id).await?; + + // After: + let is_range = range.is_some(); + let is_conditional = headers.contains_key(header::IF_NONE_MATCH); + let is_head = method == Method::HEAD; + if !is_range && !is_conditional && !is_head { + let new_views = repo.increment_views(&content_id).await?; + ``` + +### Notes +- `Method` was already imported from `axum::http::Method` at the top of the file, so no additional import was needed. +- The `serve_raw_file` handler was **not** modified; it does not have the same view-increment logic and is only accessible via GET. + +### Verification +- `cargo check -p cgcx-server` passed successfully. + +### Open Risks / Questions +- None for this task. + +### Recommended Next Step +- Proceed with remaining tasks in the security/stability batch. diff --git a/agent2_batch10.md b/agent2_batch10.md new file mode 100644 index 0000000..9e09a04 --- /dev/null +++ b/agent2_batch10.md @@ -0,0 +1,69 @@ +# Batch 10 — Documentation Updates + +## Summary +Updated `docs/COMMANDS.md` and `docs/MODERATION.md` to reflect changes from the refinement pass. + +--- + +## docs/COMMANDS.md Changes + +### 1. `/get_id` — now available in channels too (admin-only) +- Changed section header from **"Admin Commands (Group-only)"** to **"Admin Commands (Groups & Channels)"**. +- Added note: "They work in groups, supergroups, and channels where the bot is present." +- Updated `/get_id` descriptions to mention it works in groups, supergroups, and channels. + +### 2. `/help` — `[arg]` format note +- Added note to `/help` description: "Argument placeholders in the help text use `[arg]` format to avoid Telegram HTML parse errors with angle brackets." + +### 3. `/blacklist_uid` and `/whitelist_uid` — stricter restrictions + usage info +- Updated descriptions to explicitly state: **"Restricted to configured admin groups; the caller must be an admin there."** +- Added the exact usage info shown when args are missing: + - `/blacklist_uid`: `Usage: /blacklist_uid ` + - `/whitelist_uid`: `Usage: /whitelist_uid ` + +### 4. Punishment commands — propagation note +- Added to `/sban`, `/smute`, `/mute`, `/pban`, `/kick` descriptions: + - **"Propagates across all known chats when `global_ban = true`."** + +### 5. Forward submission review buttons +- Added new **"Review Message Buttons"** subsection under Forward Submissions. +- Documents the inline keyboard layout on review messages: + - Row 1: `[ Approve ]`, `[ Ignore ]` + - Row 2: `[ Blackl. ]`, `[ Ban ]`, `[ Ban/BL u. ]` +- Notes these correspond to the existing `v1:fwd:...` callbacks. + +--- + +## docs/MODERATION.md Changes + +### 1. `global_ban` config option under `[groups]` +- Reworded the **Global Ban Configuration** intro to clarify: "The `[groups]` section in the config contains the optional `global_ban` flag (default `false`)." +- Included `/kick` in the list of propagated commands. + +### 2. Propagated punishments recorded per-chat +- Added **"Propagation Behavior"** subsection. +- Documents that each propagated punishment is a **separate row** in `punishments` with its own `chat_id`. +- Explains implications: + - Background expiration revokes each independently. + - Manual `/rmute`/`/rban` only affects the local chat. + - Bot skips chats where it is not an admin and logs a warning. + +### 3. Hash blacklist behavior (migration 007, `HashBlacklistRepo`) +- Added new **"Hash Blacklist"** section. +- Includes the `007_hash_blacklist.sql` schema. +- Documents `HashBlacklistRepo::insert` and `HashBlacklistRepo::contains`. +- Describes pipeline behavior: BLAKE3 plaintext hash is checked against the blacklist **before** deduplication and persistence; blocked hashes raise `BlockedHash` and discard the temp file. + +### 4. Username tracking (`uname_changes_path` config) +- Added new **"Username Tracking"** section. +- Documents config placement (top-level, default `"data/uname_changes.json"`). +- Explains trigger: `UserRepo::ensure_exists` on every message/callback. +- Shows JSON line format with `timestamp`, `user_id`, `chat_id`, `old_username`, `new_username`. +- Notes append-mode file handling. + +--- + +## Changed Files +- `docs/COMMANDS.md` +- `docs/MODERATION.md` +- `progress.md` (updated) diff --git a/agent2_batch2.md b/agent2_batch2.md new file mode 100644 index 0000000..1ebcc7c --- /dev/null +++ b/agent2_batch2.md @@ -0,0 +1,132 @@ +# Bot-Side Report Handling Compatibility Assessment (Batch 2) + +## 1. Bot Report Format Details + +**Location:** `crates/cgcx-bot/src/main.rs:1720-1730` + +The bot constructs the forwarded report message as follows (HTML parse mode): + +```html +[ NEW REPORT ] #{report_id} + +CXID: {cxid} +Reporter: {reporter_id} +Owner: {content.user_id} +Uploaded: {YYYY-MM-DD HH:MM} +Files: 1 +``` + +**Notes:** +- `report_id` is the auto-incremented SQLite row ID returned by `ReportRepo::insert`. +- `cxid` is extracted from the user’s message via `extract_cxid(text)`. +- `reporter_id` is the Telegram `user_id` of the person reporting. +- `content.user_id` is the owner/uploader of the reported content. +- `Files: 1` is **hardcoded** to `1` regardless of actual file count. + +--- + +## 2. Inline Keyboard Layout Details + +**Location:** `crates/cgcx-bot/src/main.rs:1732-1742` + +The inline keyboard is a 2×2 grid: + +| Row | Button Label | Callback Data | +|-----|--------------|---------------| +| 1 | `[ Rmv + Ban ]` | `v1:admin:delblk:{report_id}` | +| 1 | `[ Delete Only ]` | `v1:admin:del:{report_id}` | +| 2 | `[ Blacklist Only ]` | `v1:admin:blk:{report_id}` | +| 2 | `[ Ignore ]` | `v1:admin:ign:{report_id}` | + +These callbacks are handled by `handle_admin_callback` (`main.rs:1745`), which: +- Validates the user is an admin in the review group chat. +- Looks up the `Report` by `report_id`. +- Performs the requested moderation action and resolves the report. + +--- + +## 3. `ReportRepo::insert` Signature & Behavior + +**Location:** `crates/cgcx-db/src/repos.rs:426-433` + +```rust +pub async fn insert( + &self, + content_id: &ContentId, + reporter_user_id: i64, + reason: &str, +) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO reports (content_id, reporter_user_id, reason) VALUES (?1, ?2, ?3)", + params![content_id.as_str(), reporter_user_id, reason], + )?; + Ok(conn.last_insert_rowid()) +} +``` + +- Returns `last_insert_rowid()` (the generated `report_id`). +- No additional validation inside the repo; the caller is responsible for ensuring `content_id` exists. + +--- + +## 4. Compatibility Assessment for Web Reports (`reporter_user_id = 0`) + +### ❌ Critical Issue: Foreign Key Constraint Violation + +**Schema:** `migrations/001_init.sql:39-50` + +```sql +CREATE TABLE reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id TEXT NOT NULL REFERENCES contents(id), + reporter_user_id INTEGER NOT NULL REFERENCES users(id), + reason TEXT NOT NULL, + ... +); +``` + +**Foreign key enforcement is enabled:** +- `cgcx-db/src/lib.rs:21` and `:33` both execute `PRAGMA foreign_keys = ON;`. + +**Impact:** +- Passing `reporter_user_id = 0` to `ReportRepo::insert` will **fail with a foreign key constraint violation** because there is no user row with `id = 0`. +- There is **no anonymous/web user seed** or special-case handling anywhere in the codebase. + +### ⚠️ Secondary Issue: Reason Field Semantics + +**Location:** `crates/cgcx-bot/src/main.rs:1719` + +```rust +let report_id = report_repo.insert(&content_id, reporter_id, text).await?; +``` + +- In the bot flow, `text` is the raw user message (a cxid or share link). The bot stores this raw cxid/link as the `reason`. +- A web report API would naturally accept separate `cxid` and `reason` fields. If the server replicates the bot behavior by passing the cxid as the reason, the database will contain a machine ID instead of a human-readable report reason. +- **Recommendation:** The server should pass the user-supplied human reason (or a placeholder like `"Web report"`) to `ReportRepo::insert`, not the cxid. + +### ⚠️ Tertiary Issue: Reporter Display + +- The report message displays `Reporter: {reporter_id}`. If `reporter_id = 0`, moderators will see `Reporter: 0`, which is indistinguishable from a real user and not user-friendly. +- **Recommendation:** Consider creating a dedicated anonymous/web reporter user (e.g., `id = 0` or a negative sentinel) with a recognizable username, or adjust the report template to show `"Web"` / `"Anonymous"` when the reporter is not a Telegram user. + +--- + +## 5. Recommendations + +1. **Create an anonymous/web reporter user row** (e.g., `id = 0` or a dedicated negative ID) in the `users` table before any web report can be inserted, **OR** relax the `NOT NULL` / foreign-key constraint on `reporter_user_id` (requires migration). +2. **Update the server-side report endpoint** to accept a separate `reason` field and pass it to `ReportRepo::insert`, rather than mirroring the bot’s cxid-as-reason behavior. +3. **Align the report message template** for web reports so that the `Reporter:` line is meaningful (e.g., `"Reporter: Web"` or `"Reporter: Anonymous"`) instead of a raw numeric `0`. +4. **Optional:** Fix the hardcoded `Files: 1` in the bot template to use the actual file count from `ContentFileRepo::list_by_content`, so the server report and bot report are consistent. + +--- + +## 6. Summary Table + +| Aspect | Bot Behavior | Web Report Compatibility | Risk | +|--------|--------------|--------------------------|------| +| Message format | HTML with hardcoded `Files: 1` | Server can replicate easily | Low (cosmetic) | +| Keyboard layout | 2×2 grid with `v1:admin:*:{id}` | Fully compatible | None | +| `ReportRepo::insert` | Accepts any `i64` for `reporter_user_id` | **Fails at runtime for `0`** | **High** | +| `reason` field | Stores raw cxid/link | Misleading if replicated verbatim | Medium | +| Reporter display | Raw numeric ID | `0` is uninformative | Low | diff --git a/agent2_batch3.md b/agent2_batch3.md new file mode 100644 index 0000000..e2db6ee --- /dev/null +++ b/agent2_batch3.md @@ -0,0 +1,47 @@ +# Batch 3 Bot Regression Check + +## Cargo Check Result +``` +$ cargo check -p cgcx-bot + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.44s +``` +**Result:** PASS. No compilation errors or warnings. + +## Password-Related Bot Logic Inspection + +### Findings +The bot **does** contain password-related logic, but it is independent of the frontend and does not conflict with the frontend fix. + +Key areas observed in `crates/cgcx-bot/src/main.rs`: + +1. **UploadOptions struct** (line ~63) + - Contains `password: Option`. + - Default is `None`. + +2. **User password input flow** (lines ~823–829) + - In `BotState::UploadOptions`, if the user sends plain text (not a command) and no password is set yet, the bot sets `options.password = Some(text.to_string())`. + +3. **Options UI** (lines ~1339–1365) + - Displays whether a password is set: "Password: Set" or "Password: None". + - Provides a "Set Password" callback button. + +4. **Password hashing on finalize** (lines ~1421–1430) + - During `finalize_upload`, the bot hashes the plaintext password with Argon2 and stores the hash via `ctx.pipeline.create_content_entry(..., password_hash, ...)`. + +5. **Direct access link generation** (lines ~1607–1611) + - If a password is set, the bot appends `&sc=` to the generated link and shows it to the user as a "Direct Access Link". + +6. **Forward approval password generation** (lines ~1897–1912) + - In `handle_forward_callback` for the `"approve"` action, the bot generates a random 12-character alphanumeric password (`generate_direct_password`). + - Hashes it with Argon2 and updates the content row via `content_repo.update_password_hash(...)`. + - Builds the link as `/{base_url}/?cxid={id}&sc={password}`. + +### Concerns / Observations +- **No conflict with frontend fix:** The bot does not rely on the frontend to validate passwords. It generates links with the `sc` query parameter and stores hashes in the database. Frontend changes (e.g., how `sc` is read or sent) should not break bot compilation or bot-side logic. +- **Potential concern:** If the frontend fix changed the contract for how `sc` is transmitted (e.g., removed query-param support or changed it to a header), the direct-access links generated by the bot would break for end users. However, the task description implies the frontend fix was for the frontend’s own password handling, not for removing `sc` query-param support. This was not observed in the diff. +- **Security note:** The bot sends plaintext passwords in URLs (`?sc=`). This is pre-existing behavior and outside the scope of this batch. + +## Summary +- **Compilation:** Clean. +- **Password logic:** Exists in the bot, but is self-contained and does not conflict with the frontend fix. +- **No blockers identified for Batch 3.** diff --git a/agent2_batch4.md b/agent2_batch4.md new file mode 100644 index 0000000..d8e9cc0 --- /dev/null +++ b/agent2_batch4.md @@ -0,0 +1,175 @@ +# Batch 4 — Telegram Bot + Permissions Verification + +**File inspected:** `crates/cgcx-bot/src/main.rs` (2318 lines) +**Supporting files:** `crates/cgcx-crypto/src/lib.rs`, `crates/cgcx-db/src/repos.rs`, `migrations/003_forward_system.sql` + +--- + +## 1. `finalize_upload` — Submission → Review Group + +**Location:** `main.rs` lines ~1504–1595 + +| Requirement | Status | Evidence | +|-------------|--------|----------| +| Up to 10 items per batch | ✅ PASS | `let chunks: Vec<_> = decrypted.chunks(10).collect();` (line 1541) | +| Review text on LAST batch only | ✅ PASS | `if is_last { if let Some(last) = batch.last_mut() { ... set caption ... } }` (lines 1546–1559) | +| Action button message sent separately BEFORE media | ✅ PASS | `bot.send_message(ChatId(review_group_id), review_text.clone()).reply_markup(keyboard)` is called at line 1529–1533, **before** any media batches. `set_review_message_id` stores the returned message id at line 1535. | + +### Notes +- The review group text message carries the action buttons (Approve / Ignore / Blacklist / Ban / Ban+BL). +- Media batches are sent **after** the action message, so moderators can act immediately even while media is still uploading. +- Caption is attached to the last media batch item, not the action message. + +--- + +## 2. `handle_forward_callback` — "approve" Action + +**Location:** `main.rs` lines ~1940–2040 + +| Requirement | Status | Evidence | +|-------------|--------|----------| +| Up to 10 items per batch | ✅ PASS | `let chunks: Vec<_> = decrypted.chunks(10).collect();` (line 1978) | +| Caption on last batch with custom msg + author + direct link + forward link | ✅ PASS | `caption` built at lines 1966–1970 containing `forward_def.forward_message`, `author_line`, `link`, `forward_link`. Applied to last batch at lines 1989–2002. | +| Text-only fallback if no files | ✅ PASS | `if files.is_empty()` sends text-only message at line 1972. Additional fallback `if decrypted.is_empty()` at line 1982. | + +### Notes +- `author_line` respects `content.show_author` (lines 1948–1959). +- The `posted_link` is constructed from the first message of the media group or the text message, and is DM’d to the submitter (line 2020). + +--- + +## 3. Telegram API Issues + +### 3.1 Videos sent as `InputMedia::Document` instead of `InputMedia::Video` +**Status:** ⚠️ ISSUE FOUND + +**Evidence:** +- `main.rs` line 1555–1560 (review batching): + ```rust + let media = if mime_type.starts_with("image/") { + InputMedia::Photo(InputMediaPhoto::new(input_file)) + } else { + InputMedia::Document(InputMediaDocument::new(input_file)) + }; + ``` +- Same pattern in approve batching at lines 1985–1990. + +**Impact:** Videos (`video/mp4`, etc.) and audio files are sent as generic documents. In Telegram clients this means: +- No inline video player in the chat. +- No audio waveform or music player UI. +- File appears as an attachment rather than native media. + +**Fix needed:** Add `mime_type.starts_with("video/")` → `InputMedia::Video(...)` and `mime_type.starts_with("audio/")` → `InputMedia::Audio(...)`. Requires importing `InputMediaVideo`, `InputMediaAudio` from teloxide. + +--- + +### 3.2 Audio files sent as documents +**Status:** ⚠️ ISSUE FOUND (same root cause as 3.1) + +Audio (`audio/mpeg`, etc.) falls through to `InputMedia::Document`. + +--- + +### 3.3 Caption length exceeding Telegram's 1024-character limit +**Status:** ⚠️ POTENTIAL ISSUE + +**Evidence:** +- Caption built in approve path (lines 1966–1970): + ```rust + let caption = format!( + "{}\n\nSubmitted by: {}\nDirect link: {}\nForward link: {}", + escape_html(&forward_def.forward_message), + author_line, + link, + forward_link + ); + ``` +- `forward_def.forward_message` is admin-controlled and has no length validation. +- Direct link + forward link are each ~60–100 chars. +- Author line is moderate. +- Total could exceed 1024 chars if the admin sets a long forward message. + +**Impact:** `send_media_group` will fail with a Telegram API error if caption > 1024 characters. + +**Fix needed:** Truncate `forward_def.forward_message` or the final caption to ensure it stays under 1024 characters (e.g., `caption.chars().take(1024).collect()` or pre-truncate the message component). + +--- + +### 3.4 `decrypt_bytes` loads entire files into memory +**Status:** ⚠️ ISSUE FOUND + +**Evidence:** +- `cgcx-crypto/src/lib.rs` lines 102–126: `decrypt_bytes` takes `&[u8]` and returns `Vec`, accumulating all plaintext in a single `Vec`. +- In `main.rs` (lines 1538–1545 and 1975–1981): + ```rust + match tokio::fs::read(&file.stored_path).await { // entire ciphertext in memory + Ok(ciphertext) => { + match cgcx_crypto::decrypt_bytes(&ciphertext, ...) { // entire plaintext in memory + Ok(bytes) => decrypted.push((file.mime_type.clone(), bytes)), + } + } + } + ``` +- Then `InputFile::memory(bytes.clone())` clones the bytes **again** for teloxide. + +**Impact:** +- For a single large file (e.g., 500 MB video), the bot will hold: + - Ciphertext (~500 MB) + - Plaintext (~500 MB) + - Teloxide clone (~500 MB) + - Total ~1.5 GB for one file. +- In a 10-item batch, this scales linearly. +- High risk of OOM on constrained deployments. + +**Fix needed:** Use streaming decryption and `InputFile::file(path)` or a temporary file approach so the bytes are not fully materialized in RAM. This is a larger architectural change. + +--- + +## 4. `review_message_id` Storage & Editing + +**Status:** ✅ PASS + +**Evidence:** + +### Storage +- `main.rs` line 1535: + ```rust + forward_repo.set_review_message_id(submission_id, sent.id.0).await?; + ``` +- `ForwardRepo::set_review_message_id` in `crates/cgcx-db/src/repos.rs` line 679–683: + ```rust + pub async fn set_review_message_id(&self, id: i64, message_id: i32) -> Result<()> { + conn.execute( + "UPDATE forward_submissions SET review_message_id = ?1 WHERE id = ?2", + params![message_id, id], + )... + } + ``` +- Schema in `migrations/003_forward_system.sql`: + ```sql + review_message_id INTEGER, + ``` + +### Editing on resolution +| Action | Line | Edit behavior | +|--------|------|---------------| +| **approve** | ~2026 | `edit_message_text` → `[ APPROVED ] #{id}\nApproved by {user_id}`, keyboard cleared | +| **ignore** | ~2054 | `edit_message_text` → `[ IGNORED ] ...`, keyboard cleared | +| **blacklist (blk)** | ~2064 | `edit_message_text` → `[ BLACKLISTED ] ...`, keyboard cleared | +| **ban** | ~2073 | `edit_message_text` → `[ BANNED ] ...`, keyboard cleared | +| **banblk** | ~2094 | `edit_message_text` → `[ BAN/BL ] ...`, keyboard cleared | + +All paths use `submission.review_message_id` (retrieved from DB) and call `edit_message_text` with an empty keyboard, preventing further interaction. + +--- + +## Summary of Fixes Needed + +| # | Issue | Severity | Suggested Fix | +|---|-------|----------|---------------| +| 1 | Videos sent as `InputMedia::Document` | Medium | Add `mime_type.starts_with("video/")` branch using `InputMediaVideo` | +| 2 | Audio sent as `InputMedia::Document` | Medium | Add `mime_type.starts_with("audio/")` branch using `InputMediaAudio` | +| 3 | Caption may exceed 1024 chars | Medium | Truncate caption to 1024 chars before sending | +| 4 | `decrypt_bytes` + `InputFile::memory` loads entire files into RAM | High (OOM risk) | Implement streaming file decryption or write decrypted data to temp files and use `InputFile::file` | + +**No fixes needed for:** batch size logic, caption placement, action button ordering, text-only fallback, or `review_message_id` lifecycle. diff --git a/agent2_batch5_9.md b/agent2_batch5_9.md new file mode 100644 index 0000000..ab321b6 --- /dev/null +++ b/agent2_batch5_9.md @@ -0,0 +1,155 @@ +# Verification Report: Batches 5–9 + +## Batch 5 — Review Action Buttons + +**Item 1: `finalize_upload` sends keyboard with [ Approve ], [ Ignore ], [ Blackl. ], [ Ban ], [ Ban/BL u. ]** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-bot/src/main.rs:1494–1507` +- **Evidence:** + ```rust + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("[ Approve ]", format!("v1:fwd:approve:{}", submission_id)), + InlineKeyboardButton::callback("[ Ignore ]", format!("v1:fwd:ignore:{}", submission_id)), + ], + vec![ + InlineKeyboardButton::callback("[ Blackl. ]", format!("v1:fwd:blk:{}", submission_id)), + InlineKeyboardButton::callback("[ Ban ]", format!("v1:fwd:ban:{}", submission_id)), + InlineKeyboardButton::callback("[ Ban/BL u. ]", format!("v1:fwd:banblk:{}", submission_id)), + ], + ]); + ``` + +**Item 2: `handle_forward_callback` handles `ban`, `banblk`, `blk`, `approve`, `ignore` actions** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-bot/src/main.rs:1873–2121` +- **Evidence:** `match action` arm covers all five actions: + - `"approve"` → lines 1899–2053 + - `"ignore"` → lines 2054–2065 + - `"blk"` → lines 2066–2077 + - `"ban"` → lines 2078–2094 + - `"banblk"` → lines 2095–2114 + +**Item 3: Permission check (`is_admin_in_chat` on review group) is present** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-bot/src/main.rs:1901–1905` +- **Evidence:** + ```rust + if !is_admin_in_chat(bot, ChatId(forward_def.review_group_id), UserId(user_id as u64)).await { + bot.send_message(chat_id, "Unauthorized.").await?; + return Ok(()); + } + ``` + +--- + +## Batch 6 — GLOBAL_BAN + +**Item 1: `GroupsConfig` has `global_ban: bool`** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-config/src/lib.rs:66–73` +- **Evidence:** + ```rust + pub struct GroupsConfig { + pub admin_group_ids: Vec, + pub review_group_ids: Vec, + #[serde(default = "default_global_ban")] + pub global_ban: bool, + } + fn default_global_ban() -> bool { false } + ``` + +**Item 2: `propagate_punishment` checks `ctx.config.groups.global_ban`** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-bot/src/main.rs:2270–2272` +- **Evidence:** + ```rust + async fn propagate_punishment(...) { + if !ctx.config.groups.global_ban { + return; + } + ... + } + ``` + +**Item 3: Punishment commands (`/sban`, `/smute`, `/mute`, `/pban`, `/kick`) call `propagate_punishment`** +- **Status:** ✅ PASS +- **File/Lines:** + - `/sban` → `main.rs:600` + - `/smute` → `main.rs:629` + - `/mute` → `main.rs:654` + - `/pban` → `main.rs:675` + - `/kick` → `main.rs:697` +- **Evidence:** Each command inserts a local punishment row, then immediately calls `propagate_punishment(&bot, &ctx, chat_id, target_id, "...", ...).await;`. + +--- + +## Batch 9 — Username Tracking + +**Item 1: `UserRepo::ensure_exists` logs username changes to configurable path** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-db/src/repos.rs:15–41` +- **Evidence:** + ```rust + pub async fn ensure_exists(&self, id: i64, username: Option<&str>, first_name: &str, chat_id: i64, uname_changes_path: Option<&str>) -> Result<()> { + ... + if let (Some(path), Some(ref old)) = (uname_changes_path, old_username) { + if old.as_str() != username.unwrap_or("") { + Self::log_username_change(id, chat_id, Some(old.as_str()), username, path); + } + } + Ok(()) + } + ``` + `log_username_change` appends a JSON line with timestamp, user_id, chat_id, old, and new usernames. + +**Item 2: `uname_changes_path` is in config** +- **Status:** ✅ PASS +- **File/Line:** `crates/cgcx-config/src/lib.rs:19–20, 27` +- **Evidence:** + ```rust + #[serde(default = "default_uname_changes_path")] + pub uname_changes_path: String, + fn default_uname_changes_path() -> String { "data/uname_changes.json".to_string() } + ``` + Also used in bot at `main.rs:389`: + ```rust + user_repo.ensure_exists(user_id, user.username.as_deref(), &user.first_name, chat_id.0, Some(&ctx.config.uname_changes_path)).await?; + ``` + +--- + +## Batch 7 — Show/Hide Author + +**Item 1: Upload options include `toggle_author` callback** +- **Status:** ✅ PASS +- **File/Lines:** `crates/cgcx-bot/src/main.rs:1008–1014, 1346–1364` +- **Evidence:** + - Callback handler: + ```rust + "toggle_author" => { + let new_options = UploadOptions { show_author: !options.show_author, ..options }; + ... + } + ``` + - Keyboard button: + ```rust + InlineKeyboardButton::callback("[ Toggle Author ]", "v1:opt:toggle_author"), + ``` + - Display text: + ```rust + let author_text = if options.show_author { "Show author: Yes" } else { "Show author: No" }; + ``` + +--- + +## Summary + +| Batch | Feature | Status | +|-------|---------|--------| +| 5 | Review action buttons (keyboard + handler + permission check) | ✅ PASS | +| 6 | GLOBAL_BAN config + propagation | ✅ PASS | +| 7 | Show/Hide Author toggle | ✅ PASS | +| 9 | Username change tracking | ✅ PASS | + +**No issues found.** All inspected features are implemented and correctly wired. diff --git a/agent2_telegram_bot.md b/agent2_telegram_bot.md new file mode 100644 index 0000000..9d1b41b --- /dev/null +++ b/agent2_telegram_bot.md @@ -0,0 +1,215 @@ +# Agent 2: Telegram Bot Fixes (Tasks B, C, E) + +## Summary +Fixed three issues in `crates/cgcx-bot/src/main.rs`: +- **Task B**: Enabled admin commands (including `/get_id`) in channels. +- **Task C**: Escaped angle-bracket placeholders in `/help` text to prevent Telegram HTML parse errors. +- **Task E**: Restricted `/blacklist_uid` and `/whitelist_uid` to admin groups at the outer dispatch level and removed redundant inner checks. + +--- + +## Task B — /get_id in channels + +**Problem:** Admin command dispatch was gated behind `msg.chat.is_group() || msg.chat.is_supergroup()`, so channels were excluded. + +**Change:** Added `|| msg.chat.is_channel()` to the dispatch guard. + +**oldText:** +```rust + // Admin commands in groups + if msg.chat.is_group() || msg.chat.is_supergroup() { +``` + +**newText:** +```rust + // Admin commands in groups + if msg.chat.is_group() || msg.chat.is_supergroup() || msg.chat.is_channel() { +``` + +--- + +## Task C — /help HTML parse errors + +**Problem:** The `help_text` raw string contained unescaped placeholders like ``, `<@username>`, ``, ``, ``, ``. Telegram HTML parse mode rejects unsupported tags. + +**Change:** Replaced every `` placeholder with `[arg]` (e.g., `` → `[ID]`). Existing `<dest>` / `<review>` were left untouched because they were already properly escaped. + +**oldText:** +```rust + let help_text = r#"Admin Commands + +/reload — Reload moderation lists. +/blacklist_uid — Blacklist a user ID. +/whitelist_uid — Remove a user from blacklist. +/help — Show this message. +/get_id — Get current chat ID. +/get_id <@username> — Search administrators by username. +/get_id — Search members in this chat by display name. +/create_submit_forward <dest> <review> [msg] — Create a submission forward. +/show_c_forward [page] — List forward links. +/add_blacklist — Blacklist a user in all active forwards. +/rm_blacklist — Remove a user from blacklist in all active forwards. +/sban @user [reason] — Ban for duration +/smute @user [reason] — Mute for duration +/mute @user [reason] — Mute indefinitely +/pban @user [reason] — Permanent ban +/kick @user [reason] — Kick from group +/rmute @user — Revoke mute +/rban @user — Revoke ban"#; +``` + +**newText:** +```rust + let help_text = r#"Admin Commands + +/reload — Reload moderation lists. +/blacklist_uid [ID] — Blacklist a user ID. +/whitelist_uid [ID] — Remove a user from blacklist. +/help — Show this message. +/get_id — Get current chat ID. +/get_id [@username] — Search administrators by username. +/get_id [displayname] — Search members in this chat by display name. +/create_submit_forward <dest> <review> [msg] — Create a submission forward. +/show_c_forward [page] — List forward links. +/add_blacklist [user_id] — Blacklist a user in all active forwards. +/rm_blacklist [user_id] — Remove a user from blacklist in all active forwards. +/sban @user [dur] [unit] [reason] — Ban for duration +/smute @user [dur] [unit] [reason] — Mute for duration +/mute @user [reason] — Mute indefinitely +/pban @user [reason] — Permanent ban +/kick @user [reason] — Kick from group +/rmute @user — Revoke mute +/rban @user — Revoke ban"#; +``` + +--- + +## Task E — /blacklist_uid and /whitelist_uid behavior + +**Problem:** The outer dispatch only checked `is_admin()`, then the inner handler checked `admin_group_ids`. This leaked the command's existence to non-admin groups. + +**Changes:** +1. At the outer command dispatch, both commands now require: + - `ctx.config.groups.admin_group_ids.contains(&chat_id.0)` + - `is_admin(&bot, msg.chat.id, user.id).await` +2. Removed the redundant `admin_group_ids` checks from inside `handle_admin_blacklist_uid` and `handle_admin_whitelist_uid`. +3. Missing-parameter usage replies remain intact in the handlers. + +### Outer dispatch + +**oldText:** +```rust + "/blacklist_uid" => { + tracing::info!("admin command /blacklist_uid chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } + "/whitelist_uid" => { + tracing::info!("admin command /whitelist_uid chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } +``` + +**newText:** +```rust + "/blacklist_uid" => { + tracing::info!("admin command /blacklist_uid chat={} user={}", chat_id, user_id); + if ctx.config.groups.admin_group_ids.contains(&chat_id.0) && is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } + "/whitelist_uid" => { + tracing::info!("admin command /whitelist_uid chat={} user={}", chat_id, user_id); + if ctx.config.groups.admin_group_ids.contains(&chat_id.0) && is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } +``` + +### Inner handler `handle_admin_blacklist_uid` + +**oldText:** +```rust +async fn handle_admin_blacklist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + if !ctx.config.groups.admin_group_ids.contains(&chat_id.0) { + bot.send_message(chat_id, "This command is only available in the admin group.") + .await?; + return Ok(()); + } + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); +``` + +**newText:** +```rust +async fn handle_admin_blacklist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); +``` + +### Inner handler `handle_admin_whitelist_uid` + +**oldText:** +```rust +async fn handle_admin_whitelist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + if !ctx.config.groups.admin_group_ids.contains(&chat_id.0) { + bot.send_message(chat_id, "This command is only available in the admin group.") + .await?; + return Ok(()); + } + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); +``` + +**newText:** +```rust +async fn handle_admin_whitelist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); +``` + +--- + +## Validation + +``` +$ cargo check -p cgcx-bot + Finished `dev` profile [unoptimized + debuginfo] target(s) in 45.10s +``` + +**Result:** `cargo check -p cgcx-bot` passed with no errors or warnings introduced by these changes. + +--- + +## Files Changed +- `crates/cgcx-bot/src/main.rs` +- `progress.md` (updated task list) + +## Open risks/questions +- None identified for these three tasks. + +## Recommended next step +- Continue with remaining plan tasks (if any) or run `cargo test -p cgcx-bot` if tests exist. diff --git a/agent3_batch10.md b/agent3_batch10.md new file mode 100644 index 0000000..5df9c49 --- /dev/null +++ b/agent3_batch10.md @@ -0,0 +1,56 @@ +# Agent 3 — Batch 10: Homepage Bot Link Fix + +## Task +Implement the homepage bot link fix in `frontend/src/routes/Home.svelte`. + +## Changes Made + +### File: `frontend/src/routes/Home.svelte` + +#### 1. Reordered panel DOM +**Old order:** +```svelte +
+

-- cannibal girls --

+ + + + +``` + +**New order:** +```svelte +
+ + + + + +

-- cannibal girls --

+``` + +#### 2. Updated bot link color +**Old CSS:** +```css +.bot-link a { + color: var(--retro-green); + ... +} +``` + +**New CSS:** +```css +.bot-link a { + color: var(--retro-accent); + ... +} +``` + +## Validation +- `cd frontend && npm run build` completed successfully. +- No build errors or new warnings introduced. +- `BOT_USERNAME` remains dynamically imported from `api.js`. +- Link URL remains `https://t.me/{BOT_USERNAME}?start=submit`. + +## Risks/Issues +None identified. diff --git a/agent3_batch2.md b/agent3_batch2.md new file mode 100644 index 0000000..cec7530 --- /dev/null +++ b/agent3_batch2.md @@ -0,0 +1,47 @@ +# Agent 3 — Batch 2 Implementation Report + +## Scope +Frontend-only update for Batch 2: wire the "Report Content directly" UI to call a web API instead of opening a Telegram deep link, and fix the hardcoded bot username. + +## Files Changed + +### 1. `frontend/src/lib/api.js` +- **Change:** Exported `API_BASE` so it can be imported by `Home.svelte`. +- **Before:** `const API_BASE = "http://127.0.0.1:8090";` +- **After:** `export const API_BASE = "http://127.0.0.1:8090";` + +### 2. `frontend/src/routes/Home.svelte` +- **Import:** Added `API_BASE` to the import from `../lib/api.js`. +- **State:** Added `reportStatus` and `reportStatusIsError` reactive state variables. +- **Function:** Added `submitDirectReport()` async function that: + 1. Validates `reportCxid` is non-empty. + 2. Sends `POST ${API_BASE}/api/content/${encodeURIComponent(reportCxid.trim())}/report` with JSON body `{ reason: "Direct web report" }`. + 3. On success: sets `reportStatus = "Report submitted successfully."`, clears `reportCxid`. + 4. On error: sets `reportStatus` to the error message and `reportStatusIsError = true`. +- **Template — "Report Content via Telegram":** + - Replaced hardcoded `harmfulmeowbot` with `${BOT_USERNAME}`. + - Kept deep-link parameter `?start=submit` as before. +- **Template — "Report Content directly":** + - Removed the `` wrapper around the Submit button. + - Changed the button to `onclick={submitDirectReport}`. + - Added conditional status paragraph below the input row that shows green text on success and red (`var(--retro-danger)`) on error. +- **Styling:** Added `.report-status` CSS rule inside the existing `