Initial commit

This commit is contained in:
unknown
2026-05-22 02:52:15 +02:00
commit 125321c418
55 changed files with 9231 additions and 0 deletions

91
.gitignore vendored Normal file
View File

@@ -0,0 +1,91 @@
config/default.toml
/data
/systemd
**/dist
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# rustc will dump stack traces when hitting an internal compiler error to PWD
rustc-ice-*.txt
master.key
db.sqlite
node_modules/
.node_modules/
built/*
tests/cases/rwc/*
tests/cases/perf/*
!tests/cases/webharness/compilerToString.js
test-args.txt
~*.docx
\#*\#
.\#*
tests/baselines/local/*
tests/baselines/local.old/*
tests/services/baselines/local/*
tests/baselines/prototyping/local/*
tests/baselines/rwc/*
tests/baselines/reference/projectOutput/*
tests/baselines/local/projectOutput/*
tests/baselines/reference/testresults.tap
tests/baselines/symlinks/*
tests/services/baselines/prototyping/local/*
tests/services/browser/typescriptServices.js
src/harness/*.js
src/compiler/diagnosticInformationMap.generated.ts
src/compiler/diagnosticMessages.generated.json
src/parser/diagnosticInformationMap.generated.ts
src/parser/diagnosticMessages.generated.json
rwc-report.html
*.swp
build.json
*.actual
tests/webTestServer.js
tests/webTestServer.js.map
tests/webhost/*.d.ts
tests/webhost/webtsc.js
tests/cases/**/*.js
tests/cases/**/*.js.map
*.config
scripts/eslint/built/
scripts/debug.bat
scripts/run.bat
scripts/**/*.js
scripts/**/*.js.map
coverage/
internal/
**/.DS_Store
.settings
**/.vs
**/.vscode/*
!**/.vscode/tasks.json
!**/.vscode/settings.template.json
!**/.vscode/launch.template.json
!**/.vscode/extensions.json
!tests/cases/projects/projectOption/**/node_modules
!tests/cases/projects/NodeModulesSearch/**/*
!tests/baselines/reference/project/nodeModules*/**/*
.idea
yarn.lock
yarn-error.log
.parallelperf.*
tests/baselines/reference/dt
.failed-tests
TEST-results.xml
package-lock.json
.eslintcache
*v8.log
/lib/

3713
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[workspace]
members = [
"crates/cgcx-core",
"crates/cgcx-config",
"crates/cgcx-crypto",
"crates/cgcx-db",
"crates/cgcx-storage",
"crates/cgcx-content-typing",
"crates/cgcx-file-pipeline",
"crates/cgcx-moderation",
"crates/cgcx-bot",
"crates/cgcx-server",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["cg.cx team"]
[profile.release]
lto = "thin"
codegen-units = 1
strip = true

445
README.md Normal file
View File

@@ -0,0 +1,445 @@
# cg.cx
> End-to-end encrypted content sharing via Telegram — with a modern web frontend.
**cg.cx** is a privacy-first file and text sharing platform built as a Telegram bot and Axum web service. Users upload content through a Telegram bot; the service encrypts every file with unique per-content keys, stores them securely, and shares them via short 12-character IDs. Recipients view or download content through a lightweight Svelte 5 web interface with automatic decryption on the fly.
---
## Project Overview
### What it is
cg.cx lets Telegram users upload media, documents, or plain text and receive a short shareable link (`https://cg.cx/?cxid=AbCdEfGhIjKl`). All content is encrypted at rest using **XChaCha20-Poly1305** with per-file content encryption keys (CEKs) wrapped by a master key. The server never sees plaintext.
### Key Features
| Feature | Description |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **End-to-End Encryption** | Every file is encrypted with a unique CEK using XChaCha20 secretstream; only the server (with the master key) can decrypt for delivery. |
| **Short Shareable IDs** | Content is addressed by 12-character alphanumeric IDs (e.g., `AbCdEfGhIjKl`). |
| **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; reports are routed to review groups with inline admin actions. |
| **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. |
### Architecture at a Glance
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Telegram User │────▶│ cgcx-bot │────▶│ cgcx-server │
│ (upload / cmd) │ │ (Teloxide) │ │ (Axum / web) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ cgcx-file- │────▶│ Svelte 5 │
│ pipeline │ │ Frontend │
│ (encrypt/store) │ │ (viewer) │
└─────────────────┘ └─────────────────┘
┌─────────────────┐
│ SQLite3 + WAL │
│ (metadata) │
└─────────────────┘
```
---
## Architecture
cg.cx is organized as a **Rust workspace** with 10 focused crates. This modular design separates concerns, enables independent unit testing, and allows the bot and server binaries to pull in only the crates they need.
| Crate | Purpose |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cgcx-core` | Shared domain types: `ContentId`, `User`, `Content`, `ContentFile`, `Report`, error enums, and result types. Zero external dependencies beyond `serde` and `chrono`. |
| `cgcx-config` | Hierarchical configuration loader (`config/default.toml``config/local.toml``CGCX_*` env vars) with validation. |
| `cgcx-crypto` | Cryptographic primitives: XChaCha20 secretstream encryption/decryption, AES-KW key wrapping, BLAKE3 hashing, master key loading. |
| `cgcx-db` | SQLite access layer with `rusqlite`, embedded migrations (`rusqlite_migration`), and async repository patterns for users, content, files, reports, and admin actions. |
| `cgcx-storage` | Filesystem abstraction: path generation by MIME type, directory creation, temp file handling, and cleanup. |
| `cgcx-content-typing` | MIME type detection (`infer` + `mime_guess`) and render-flag computation for safe UI handling of dangerous files. |
| `cgcx-file-pipeline` | High-level upload orchestration: ingests raw bytes, detects type, encrypts via `cgcx-crypto`, stores via `cgcx-storage`, and records metadata via `cgcx-db`. |
| `cgcx-moderation` | Runtime moderation lists (blacklist / whitelist) loaded from JSON, with configurable share modes (`b` = blocklist, `w` = allowlist) and auto-reload. |
| `cgcx-bot` | **Binary crate** — Telegram bot built on `teloxide`. Handles dialogue flows, uploads, terms acceptance, reporting, and admin commands. |
| `cgcx-server` | **Binary crate** — Axum HTTP server. Serves the Svelte frontend, streams decrypted files, enforces view limits, and validates password cookies. |
### Why a Modular Crate Structure?
- **Separation of concerns**: Crypto logic cannot accidentally depend on Telegram bot internals; the database layer knows nothing about HTTP.
- **Testability**: Each crate can be unit-tested in isolation. `cgcx-core` and `cgcx-crypto` have no async runtime requirements, making them fast to test.
- **Independent deployment**: In the future, the bot and server could be built as separate container images sharing only the library crates.
- **Compile-time enforcement**: The workspace dependency graph guarantees that, for example, `cgcx-crypto` never touches the network or filesystem directly.
---
## Security Design
### Cryptographic Primitives
| Layer | Algorithm | Purpose |
| -------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Secretstream** | XChaCha20-Poly1305 (libsodium) | Encrypts file plaintext into an authenticated ciphertext stream. |
| **Key Wrapping** | AES-KW (AES-256 Key Wrap, RFC 3394) | Wraps each per-file CEK with the master key. |
| **Integrity Hash** | BLAKE3 | Computes a hash over the ciphertext stream (including the secretstream header) for tamper detection. |
| **Password Hashing** | Argon2id | Hashes optional per-content passwords. |
| **Cookie MAC** | HMAC-SHA256 | Integrity MAC for password-verification session cookies using constant-time comparison. |
| **ID Entropy** | Rejection sampling over `[A-Za-z0-9]` | 12-character IDs provide ~71 bits of entropy. |
### Encryption Flow
1. **Generate CEK**: For every uploaded file, `cgcx-crypto` generates a random 256-bit `ContentKey`.
2. **Encrypt**: The file is fed through `sodiumoxide::crypto::secretstream::xchacha20poly1305` in chunks (up to 1 MiB). The final chunk is tagged `Final`.
3. **Hash**: A running BLAKE3 hash covers the secretstream header and every ciphertext chunk.
4. **Wrap Key**: The CEK is wrapped with AES-KW using the 256-bit master key. The wrapped key + a version byte is stored in SQLite.
5. **Store**: The ciphertext file is moved from temp storage to its final path (`data/media|documents|text/<cxid>/...`).
### Decryption Flow
1. **Unwrap CEK**: The server unwraps the per-file CEK using the master key.
2. **Init Stream**: `DecryptStream` is initialized with the stored secretstream header.
3. **Stream**: Ciphertext is read from disk in ~1 MiB chunks, decrypted, and pushed to the HTTP response body via a Tokio channel.
4. **Verify**: If decryption fails (tampered or truncated data), the stream aborts and the client receives a broken stream.
### Password Protection
- Passwords are hashed with **Argon2id** and stored in the `contents` table.
- On successful verification, the server issues an `__Host-pw` cookie containing a base64-encoded `cxid:MAC` pair.
- The MAC is computed via **HMAC-SHA256** over the content ID using a server-side secret (derived from the master key).
- Cookie attributes: `Secure`, `HttpOnly`, `SameSite=Strict`, `Max-Age=3600`.
### Master Key Handling
- The master key is a 256-bit value loaded from either an environment variable (`CGCX_AES_MASTER_KEY`) or a file.
- If loaded from a file, the key is expected as 64 hex characters.
- On Unix systems, newly generated key files are automatically chmodded to `0o600`.
- The key fingerprint (first 8 bytes of BLAKE3 hash) is logged at startup for audit purposes; the full key is never logged.
---
## Tech Stack
| Layer | Technology |
| ----------------- | ----------------------------------------------------------- |
| **Backend** | Rust (edition 2021), Tokio async runtime |
| **Web Server** | Axum 0.7, Tower HTTP middleware |
| **Telegram Bot** | Teloxide 0.13 |
| **Frontend** | Svelte 5, Vite 5 |
| **Database** | SQLite 3 (WAL mode), `rusqlite` + `rusqlite_migration` |
| **Cryptography** | libsodium (via `sodiumoxide`), `aes-kw`, `blake3`, `argon2`, `hmac`, `sha2` |
| **Serialization** | `serde`, `serde_json` |
| **Observability** | `tracing` + `tracing-subscriber` |
---
## Prerequisites
- **Rust** toolchain (latest stable or nightly; the project builds on stable Rust 1.78+)
- **Node.js** 20+ and `npm` (for the frontend)
- **SQLite 3** (bundled via `rusqlite`, but the CLI is useful for inspection)
- A **Telegram Bot Token** from [@BotFather](https://t.me/botfather)
- A 256-bit master key (64 hex characters) for encryption
---
## Building
### Rust Workspace
Build all crates (library + binaries):
```bash
cargo build --workspace
```
Build optimized release binaries:
```bash
cargo build --workspace --release
```
The release profile enables thin LTO, single codegen unit, and binary stripping for minimal size.
### Frontend
```bash
cd frontend
npm install
npm run build
```
The static assets are emitted to `frontend/dist/` and served by `cgcx-server` at runtime.
---
## Configuration
cg.cx uses a layered configuration system:
1. `config/default.toml` — committed defaults
2. `config/default.example.toml` — local overrides (gitignored)
3. `CGCX_*` environment variables — runtime overrides
Environment variables use double-underscore as a separator, e.g.:
```bash
export CGCX_SERVER__PORT=3000
export CGCX_TELEGRAM__BOT_TOKEN="your_token_here"
export CGCX_CRYPTO__AES_MASTER_KEY_SOURCE__TYPE="env"
export CGCX_CRYPTO__AES_MASTER_KEY_SOURCE__VAR="CGCX_AES_MASTER_KEY"
export CGCX_AES_MASTER_KEY="aabbccdd..." # 64 hex chars
```
### Config Sections
| Section | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------- |
| `[content]` | Auto-destruct behavior (`keep_content`, `share_mode`, `default_allow_download`, `default_max_views`). |
| `[crypto]` | Master key source (`env` or `file`). |
| `[telegram]` | Bot token and optional custom API URL. |
| `[groups]` | `admin_group_ids` and `review_group_ids` (Telegram chat IDs). |
| `[storage]` | Filesystem paths for `media`, `documents`, `text`, `temp`, and the streaming chunk size. |
| `[upload_limits]` | `max_batch_size`, `max_file_size_bytes`, `max_total_batch_bytes`. |
| `[server]` | `base_url`, `bind_address`, `port`. |
| `[rate_limiting]` | Per-minute request limits, burst capacity, and password-attempt limits. |
| `[logging]` | `level` (e.g., `info`, `debug`). |
| `[frontend.behavior_toggles]` | Feature flags for retro animations and particles. |
### Validating Config
Both binaries validate configuration on startup. Key checks include:
- Chunk size between 8 MiB and 256 MiB
- Bot token is set and not the placeholder
- Upload and rate-limiting values are non-zero
- Master key source is fully specified
---
## Running
### Run the Web Server
```bash
cargo run -p cgcx-server
```
The server binds to `127.0.0.1:8080` by default and serves:
- `/` — Svelte frontend
- `/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
- `/assets/*` — static frontend assets
### Run the Telegram Bot
```bash
cargo run -p cgcx-bot
```
The bot processes updates from Telegram, handles user dialogues, and triggers the file pipeline for uploads.
### Run Both Simultaneously
Because the bot and server are separate binaries, they can run side-by-side sharing the same SQLite database and data directories:
```bash
# Terminal 1
cargo run -p cgcx-server
# Terminal 2
cargo run -p cgcx-bot
```
Ensure both processes point to the same database path and storage directories via shared configuration.
---
## Database Migrations
Migrations are managed by `rusqlite_migration` and embedded into the `cgcx-db` crate at compile time.
- `migrations/001_init.sql` — Creates `users`, `contents`, `content_files`, `reports`, and `admin_actions` tables.
- `migrations/002_indexes.sql` — Adds performance indexes on foreign keys, status columns, and report state.
On startup, both the bot and server call `db.run_migrations()`, which applies any pending migrations automatically. The database is opened with:
- `PRAGMA journal_mode = WAL;`
- `PRAGMA foreign_keys = ON;`
- `PRAGMA busy_timeout = 5000;`
### Manual Inspection
```bash
sqlite3 data/db.sqlite ".schema"
sqlite3 data/cgcx.db ".indexes"
```
---
## Deployment
### systemd Service
Create `/etc/systemd/system/cgcx-server.service`:
```ini
[Unit]
Description=cg.cx Web Server
After=network.target
[Service]
Type=simple
User=cgcx
Group=cgcx
WorkingDirectory=/opt/cgcx
Environment="RUST_LOG=info"
Environment="CGCX_AES_MASTER_KEY=<64-hex-chars>"
ExecStart=/opt/cgcx/cgcx-server
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Create a similar service for `cgcx-bot`. Reload and enable:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now cgcx-server cgcx-bot
```
### Reverse Proxy (nginx)
```nginx
server {
listen 443 ssl http2;
server_name cg.cx;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Support streaming
proxy_buffering off;
proxy_request_buffering off;
}
}
```
### TLS
Use Let's Encrypt (certbot) or a managed TLS terminator. The `__Host-pw` cookie requires HTTPS (`Secure` flag).
### File Permissions
- The master key file (if used instead of env) **must** be readable only by the service user:
```bash
chmod 600 /opt/cgcx/master.key
chown cgcx:cgcx /opt/cgcx/master.key
```
- Data directories (`data/media`, `data/documents`, `data/text`, `data/temp`) should be owned by the service user.
---
## Administration
### Admin Commands
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 <user_id>` | Blacklists a Telegram user ID and sets their role to `banned`. |
| `/whitelist_uid` | `/whitelist_uid <user_id>` | Whitelists a Telegram user ID (relevant in whitelist mode). |
### Review Groups
Reports submitted by users are forwarded to all configured `review_group_ids` with an inline keyboard:
- **🗑 Delete** — Sets content status to `deleted`.
- **⛔ Blacklist User** — Blacklists the uploader and bans them.
- **📝 Ignore** — Dismisses the report.
### Moderation Modes
- **Blocklist mode (`share_mode = "b"`)**: Everyone can upload except blacklisted IDs.
- **Allowlist mode (`share_mode = "w"`)**: Only whitelisted IDs can upload.
Moderation lists are hot-reloaded every 30 seconds by a background task, or immediately via `/reload`.
---
## Development
### Dev Mode (Frontend)
```bash
cd frontend
npm install
npm run dev
```
Vite dev server runs separately; point `config/local.toml` `server.base_url` to your local frontend proxy if needed.
### Dev Mode (Backend)
```bash
# Server with tracing
cargo run -p cgcx-server
# Bot with tracing
cargo run -p cgcx-bot
```
Set `RUST_LOG=debug` for verbose output:
```bash
RUST_LOG=debug cargo run -p cgcx-server
```
### Testing
Run workspace tests:
```bash
cargo test --workspace
```
Individual crate tests:
```bash
cargo test -p cgcx-core
cargo test -p cgcx-crypto
cargo test -p cgcx-content-typing
```
### Useful Debug Tips
- Inspect SQLite directly: `sqlite3 data/db.sqlite "SELECT * FROM contents;"`
- Check moderation lists: `cat data/blacklisted_ids.json`
- Verify master key fingerprint in logs on startup.
---
## License
MIT License — see [LICENSE](LICENSE) for details.
---
## Security Disclosure
If you discover a security vulnerability, please do not open a public issue. Contact the maintainers directly through the admin channels configured in the bot.

123
config/default.example.toml Normal file
View File

@@ -0,0 +1,123 @@
# ============================================================================
# CG.CX Default Configuration
# ============================================================================
# Copy this file to config/local.toml and override values there.
# Environment variables prefixed with CGCX__ also override these values.
# Example: CGCX_TELEGRAM__BOT_TOKEN=your_token
# ============================================================================
# ----------------------------------------------------------------------------
# Content settings
# ----------------------------------------------------------------------------
[content]
# Whether to keep content files on disk after deletion/blacklisting.
# If false, files are physically deleted when content is removed.
keep_content = true
# Share mode: "b" = blacklist mode (allow unless blacklisted)
# "w" = whitelist mode (deny unless whitelisted)
share_mode = "b"
# Default download permission for new uploads.
default_allow_download = true
# Default max views before auto-destruction. Omit or comment out for no limit.
# default_max_views = 10
# ----------------------------------------------------------------------------
# Cryptography
# ----------------------------------------------------------------------------
[crypto]
# Master key source. The master key is used to wrap per-file content keys.
# Options:
# { type = "file", path = "data/master.key" } -- auto-generates if missing
# { type = "env", var = "CGCX_AES_MASTER_KEY" } -- reads from env var
#
# File-based is recommended for first setup because it auto-generates.
aes_master_key_source = { type = "file", path = "data/master.key" }
# ----------------------------------------------------------------------------
# Telegram Bot
# ----------------------------------------------------------------------------
[telegram]
# Bot token from @BotFather. REQUIRED.
bot_token = "BOT_TOKEN_PLACEHOLDER"
# Optional: local Bot API server URL for files > 20MB.
# Leave commented if using default Telegram servers.
# api_url = "http://localhost:8081"
# ----------------------------------------------------------------------------
# Telegram Groups
# ----------------------------------------------------------------------------
[groups]
# Group IDs where admin commands (/reload, /blacklist_uid, /whitelist_uid) work.
# Negative IDs for supergroups. Example: [-1001234567890]
admin_group_ids = []
# Group IDs where reported content is forwarded for moderator review.
review_group_ids = []
# ----------------------------------------------------------------------------
# Storage Paths
# ----------------------------------------------------------------------------
[storage]
# Directory layout for encrypted files.
paths = { media = "./data/media", documents = "./data/documents", text = "./data/text", temp = "./data/temp" }
# Chunk size for streaming upload/download. Clamped to [8 MiB, 256 MiB].
chunk_size_bytes = 67_108_864 # 64 MiB
# ----------------------------------------------------------------------------
# Upload Limits
# ----------------------------------------------------------------------------
[upload_limits]
# Maximum number of files per content entry.
max_batch_size = 10
# Maximum size of a single file (bytes).
max_file_size_bytes = 838_860_800 # 800 MiB
# Maximum total size of all files in one batch (bytes).
max_total_batch_bytes = 2_147_483_648 # 2 GiB
# ----------------------------------------------------------------------------
# HTTP Server
# ----------------------------------------------------------------------------
[server]
# Public base URL used in share links. MUST match your reverse proxy / TLS.
base_url = "https://cg.cx"
# Bind address and port for the Axum server.
bind_address = "127.0.0.1"
port = 8080
# ----------------------------------------------------------------------------
# Rate Limiting
# ----------------------------------------------------------------------------
[rate_limiting]
# General API requests per minute (per IP).
requests_per_minute = 60
# Burst capacity for token bucket.
burst = 10
# Password attempt limit per minute (per content ID).
password_attempts_per_minute = 5
# ----------------------------------------------------------------------------
# Logging
# ----------------------------------------------------------------------------
[logging]
# Log level: trace, debug, info, warn, error
level = "info"
# ----------------------------------------------------------------------------
# Frontend Behavior Toggles
# ----------------------------------------------------------------------------
[frontend.behavior_toggles]
# Enable retro CRT-style loading animation.
enable_retro_animation = true
# Enable floating particle background (desktop only).
enable_particles = true

View File

@@ -0,0 +1,32 @@
[package]
name = "cgcx-bot"
version.workspace = true
edition.workspace = true
[[bin]]
name = "cgcx-bot"
path = "src/main.rs"
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-config = { path = "../cgcx-config" }
cgcx-db = { path = "../cgcx-db" }
cgcx-file-pipeline = { path = "../cgcx-file-pipeline" }
cgcx-moderation = { path = "../cgcx-moderation" }
cgcx-storage = { path = "../cgcx-storage" }
cgcx-crypto = { path = "../cgcx-crypto" }
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
teloxide = { version = "0.13", features = ["macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
regex = "1"
argon2 = "0.5"
password-hash = "0.5"
hmac = "0.12"
sha2 = "0.10"
rand = "0.8"
fs2 = "0.4.3"

View File

@@ -0,0 +1 @@
pub fn placeholder() {}

988
crates/cgcx-bot/src/main.rs Normal file
View File

@@ -0,0 +1,988 @@
use std::sync::Arc;
use teloxide::{
dispatching::{dialogue::{InMemStorage, Storage}, UpdateFilterExt},
net::Download,
prelude::*,
types::{
InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, CallbackQuery,
ChatMemberStatus, UserId,
},
utils::command::BotCommands,
};
use cgcx_config::Config;
use cgcx_core::{ContentId, ContentStatus, ReportStatus, UserRole};
use cgcx_crypto::MasterKey;
use cgcx_db::{Database, UserRepo, ContentRepo, ContentFileRepo, ReportRepo};
use cgcx_file_pipeline::FilePipeline;
use cgcx_moderation::ModerationEngine;
use cgcx_storage::Storage as CgcxStorage;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
#[derive(Clone, Default, Serialize, Deserialize)]
pub enum BotState {
#[default]
Start,
TermsPending,
MainMenu,
UploadStaging { items: Vec<StagedItem>, upload_type: UploadType },
UploadOptions { items: Vec<StagedItem>, options: UploadOptions },
UploadFinalizing,
Reporting,
ViewingPrevious { page: usize },
}
#[derive(Clone, Copy, Serialize, Deserialize)]
pub enum UploadType {
Media,
Document,
Text,
}
impl Default for UploadType {
fn default() -> Self { UploadType::Media }
}
#[derive(Clone, Serialize, Deserialize)]
pub struct StagedItem {
pub file_id: String,
pub file_name: String,
pub mime_type: String,
pub size: u64,
pub caption: Option<String>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct UploadOptions {
pub max_views: Option<u64>,
pub allow_download: bool,
pub password: Option<String>,
}
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
#[derive(Clone)]
struct BotDialogue {
chat_id: ChatId,
storage: Arc<InMemStorage<BotState>>,
}
impl BotDialogue {
async fn get(&self) -> Result<BotState, Box<dyn std::error::Error + Send + Sync>> {
Ok(self.storage.clone().get_dialogue(self.chat_id).await?.unwrap_or_default())
}
async fn update(&self, state: BotState) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(self.storage.clone().update_dialogue(self.chat_id, state).await?)
}
async fn reset(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(self.storage.clone().remove_dialogue(self.chat_id).await?)
}
async fn exit(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.reset().await
}
async fn get_or_default(&self) -> Result<BotState, Box<dyn std::error::Error + Send + Sync>> {
self.get().await
}
}
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Commands:")]
#[allow(dead_code)]
enum Command {
#[command(description = "Start the bot")]
Start,
}
const TERMS_TEXT: &str = r#"<b>Welcome to CG.CX</b>
Before using this service, you must read and accept the following terms:
1. <b>No Responsibility:</b> This service is not responsible for whatever media or files are shared by users.
2. <b>Content Warning:</b> Content may be uncomfortable, including bloody scenes or other disturbing material.
3. <b>Prohibited Content:</b> You must NOT upload:
• Doxes / doxxing material
• CSAM or any sexual content involving minors
• Animal cruelty material
• Malware, stealers, droppers, loaders, ransomware, weaponized files, or intentionally malicious binaries/scripts
• Suicide guides, self-harm instructions, or material explicitly intended to facilitate suicide
4. <b>Enforcement:</b> Violating these rules may result in immediate deletion, reporting to authorities, blacklisting, or moderator escalation.
<b>By clicking "Accept", you confirm you are at least 18 years old and agree to these terms.</b>"#;
#[derive(Clone)]
struct BotContext {
db: Arc<Database>,
#[allow(dead_code)]
storage: Arc<CgcxStorage>,
config: Arc<Config>,
master_key: Arc<MasterKey>,
moderation: Arc<ModerationEngine>,
pipeline: Arc<FilePipeline>,
sem: Arc<tokio::sync::Semaphore>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let config = Arc::new(Config::load().expect("Failed to load config"));
let db = Arc::new(Database::open("data/db.sqlite").expect("Failed to open database"));
db.run_migrations().await.expect("Failed to run migrations");
let storage = Arc::new(CgcxStorage::new(config.storage.paths.clone()));
storage.ensure_dirs().await.expect("Failed to ensure storage dirs");
let master_key = match &config.crypto.aes_master_key_source {
cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var).expect("Failed to load master key"),
cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path).expect("Failed to load master key"),
};
master_key.log_startup(false);
let moderation = Arc::new(ModerationEngine::new(&config, std::path::PathBuf::from("data")));
moderation.load().await.expect("Failed to load moderation lists");
let pipeline = Arc::new(FilePipeline::new(
(*storage).clone(),
(*db).clone(),
(*config).clone(),
));
let sem = Arc::new(tokio::sync::Semaphore::new(
(1024 * 1024 * 1024 / config.storage.chunk_size_bytes.max(1)).max(1)
));
let ctx = BotContext {
db, storage, config: config.clone(),
master_key: Arc::new(master_key), moderation, pipeline, sem,
};
let bot = Bot::new(&config.telegram.bot_token);
info!("Bot started");
let handler = dptree::entry()
.branch(Update::filter_message().endpoint(handle_message))
.branch(Update::filter_callback_query().endpoint(handle_callback));
Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![InMemStorage::<BotState>::new(), ctx])
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
}
async fn handle_message(
bot: Bot,
msg: Message,
storage: Arc<InMemStorage<BotState>>,
ctx: BotContext,
) -> HandlerResult {
let user = match &msg.from {
Some(u) => u.clone(),
None => return Ok(()),
};
let chat_id = msg.chat.id;
let user_id = user.id.0 as i64;
let dialogue = BotDialogue { chat_id, storage };
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.ensure_exists(user_id, user.username.as_deref(), &user.first_name).await?;
let db_user = match user_repo.get(user_id).await? {
Some(u) => u,
None => return Ok(()),
};
if matches!(db_user.role, UserRole::Banned) || !ctx.moderation.is_allowed(user_id).await {
bot.send_message(chat_id, "[ Banned ] You are not allowed to use this service.").await?;
dialogue.exit().await?;
return Ok(());
}
// Admin commands in groups
if msg.chat.is_group() || msg.chat.is_supergroup() {
if let Some(text) = msg.text() {
let cmd = text.split_whitespace().next().unwrap_or("");
match cmd {
"/reload" => {
if is_admin(&bot, msg.chat.id, user.id).await {
ctx.moderation.load().await?;
bot.send_message(chat_id, "Moderation lists reloaded.").await?;
}
return Ok(());
}
"/blacklist_uid" => {
if is_admin(&bot, msg.chat.id, user.id).await {
handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?;
}
return Ok(());
}
"/whitelist_uid" => {
if is_admin(&bot, msg.chat.id, user.id).await {
handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?;
}
return Ok(());
}
_ => {}
}
}
}
// DM commands
if let Some(text) = msg.text() {
let cmd = text.split_whitespace().next().unwrap_or("").split('@').next().unwrap_or("");
match cmd {
"/start" => {
if db_user.accepted_terms_at.is_some() {
return send_main_menu(&bot, chat_id, &dialogue).await;
} else {
return send_terms(&bot, chat_id, &dialogue).await;
}
}
"/cancel" => {
dialogue.update(BotState::MainMenu).await?;
return send_main_menu(&bot, chat_id, &dialogue).await;
}
_ => {}
}
}
let state = dialogue.get_or_default().await?;
match state {
BotState::Start | BotState::TermsPending => {
if msg.text().map(|t| t == "/start").unwrap_or(false) {
send_terms(&bot, chat_id, &dialogue).await?;
}
}
BotState::UploadStaging { items, upload_type } => {
handle_staging_message(&bot, msg, &dialogue, &ctx, items, upload_type).await?;
}
BotState::UploadOptions { items, options } => {
if let Some(text) = msg.text() {
if !text.starts_with('/') && options.password.is_none() {
let mut new_options = options.clone();
new_options.password = Some(text.to_string());
let items_cloned = items.clone();
dialogue.update(BotState::UploadOptions { items: items_cloned.clone(), options: new_options.clone() }).await?;
refresh_options_message(&bot, chat_id, &items_cloned, &new_options).await?;
}
}
}
BotState::Reporting => {
if let Some(text) = msg.text() {
if !text.starts_with('/') {
handle_report(&bot, chat_id, user_id, text, &dialogue, &ctx).await?;
}
}
}
_ => {}
}
Ok(())
}
async fn handle_callback(
bot: Bot,
q: CallbackQuery,
storage: Arc<InMemStorage<BotState>>,
ctx: BotContext,
) -> HandlerResult {
let data = q.data.as_deref().unwrap_or("");
let user = q.from;
let user_id = user.id.0 as i64;
if !ctx.moderation.is_allowed(user_id).await {
bot.answer_callback_query(&q.id).text("Not allowed").await?;
return Ok(());
}
let chat_id = q.message.as_ref().map(|m| m.chat().id).unwrap_or(ChatId(user_id));
let dialogue = BotDialogue { chat_id, storage };
let parts: Vec<&str> = data.split(':').collect();
if parts.len() < 3 || parts[0] != "v1" {
bot.answer_callback_query(&q.id).await?;
return Ok(());
}
match parts[1] {
"terms" => match parts[2] {
"accept" => {
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.set_accepted_terms(user_id).await?;
if let Some(msg) = &q.message {
bot.delete_message(chat_id, msg.id()).await.ok();
}
send_main_menu(&bot, chat_id, &dialogue).await?;
}
"reject" => {
if let Some(msg) = &q.message {
bot.delete_message(chat_id, msg.id()).await.ok();
}
dialogue.reset().await?;
}
_ => {}
},
"menu" => match parts[2] {
"upload_media" => {
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
send_staging_message(&bot, chat_id, &[], UploadType::Media).await?;
}
"upload_doc" => {
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document }).await?;
send_staging_message(&bot, chat_id, &[], UploadType::Document).await?;
}
"upload_text" => {
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text }).await?;
send_staging_message(&bot, chat_id, &[], UploadType::Text).await?;
}
"prev_uploads" => {
dialogue.update(BotState::ViewingPrevious { page: 0 }).await?;
show_previous_uploads(&bot, chat_id, user_id, 0, &ctx).await?;
}
"report" => {
dialogue.update(BotState::Reporting).await?;
bot.send_message(chat_id, "Send me the content link or content ID to report.").await?;
}
"main" => {
send_main_menu(&bot, chat_id, &dialogue).await?;
}
_ => {}
},
"stage" => match parts[2] {
"confirm" => {
let state = dialogue.get_or_default().await?;
if let BotState::UploadStaging { items, .. } = state {
if items.is_empty() {
bot.answer_callback_query(&q.id).text("No items to upload.").await?;
} else {
let options = UploadOptions {
allow_download: true,
..Default::default()
};
dialogue.update(BotState::UploadOptions { items, options: options.clone() }).await?;
refresh_options_message(&bot, chat_id, &vec![], &options).await?;
}
}
}
"cancel" => {
if let Some(msg) = &q.message {
bot.edit_message_text(chat_id, msg.id(), "Upload cancelled.").await.ok();
}
dialogue.update(BotState::MainMenu).await?;
}
_ => {}
},
"opt" => match parts[2] {
"toggle_destroy" => {
let state = dialogue.get_or_default().await?;
if let BotState::UploadOptions { items, options } = state {
let cycle = [None, Some(1), Some(3), Some(5), Some(10), Some(50)];
let current = options.max_views;
let next = cycle.iter().skip_while(|&&x| x != current).nth(1).copied().unwrap_or(None);
let new_options = UploadOptions { max_views: next, ..options };
dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?;
refresh_options_message(&bot, chat_id, &items, &new_options).await?;
}
}
"toggle_download" => {
let state = dialogue.get_or_default().await?;
if let BotState::UploadOptions { items, options } = state {
let new_options = UploadOptions { allow_download: !options.allow_download, ..options };
dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?;
refresh_options_message(&bot, chat_id, &items, &new_options).await?;
}
}
"set_password" => {
bot.send_message(chat_id, "Send the password (max 32 chars) or /skip to skip.").await?;
}
"confirm_final" => {
let state = dialogue.get_or_default().await?;
if let BotState::UploadOptions { items, options } = state {
dialogue.update(BotState::UploadFinalizing).await?;
finalize_upload(&bot, chat_id, user_id, items, options, &dialogue, &ctx).await?;
}
}
"back" => {
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
send_staging_message(&bot, chat_id, &[], UploadType::Media).await?;
}
_ => {}
},
"prev" => {
if parts[2] == "page" {
if let Ok(page) = parts[3].parse::<usize>() {
dialogue.update(BotState::ViewingPrevious { page }).await?;
show_previous_uploads(&bot, chat_id, user_id, page, &ctx).await?;
}
}
}
"admin" => {
if parts.len() >= 4 {
handle_admin_callback(&bot, chat_id, user_id, &parts, &ctx).await?;
}
}
_ => {}
}
bot.answer_callback_query(&q.id).await?;
Ok(())
}
async fn send_terms(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult {
let keyboard = InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback("[ Accept ]", "v1:terms:accept"),
InlineKeyboardButton::callback("[ Decline ]", "v1:terms:reject"),
]]);
bot.send_message(chat_id, TERMS_TEXT)
.parse_mode(ParseMode::Html)
.reply_markup(keyboard)
.await?;
dialogue.update(BotState::TermsPending).await?;
Ok(())
}
async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult {
let keyboard = InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("[ Upload Media ]", "v1:menu:upload_media"),
InlineKeyboardButton::callback("[ Upload Docs ]", "v1:menu:upload_doc"),
],
vec![
InlineKeyboardButton::callback("[ Upload Text ]", "v1:menu:upload_text"),
InlineKeyboardButton::callback("[ Previous Uploads ]", "v1:menu:prev_uploads"),
],
vec![
InlineKeyboardButton::callback("[ Report Content ]", "v1:menu:report"),
],
]);
bot.send_message(chat_id, "Choose from the menu below. Administrators can be contacted here: @harmfulmeowbot")
.reply_markup(keyboard)
.await?;
dialogue.update(BotState::MainMenu).await?;
Ok(())
}
async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem], upload_type: UploadType) -> HandlerResult {
let type_label = match upload_type {
UploadType::Media => "Media",
UploadType::Document => "Documents",
UploadType::Text => "Text",
};
let text = if items.is_empty() {
format!("[ Staging {} (0/10) ]\n\nSend me files to add them.", type_label)
} else {
let list: String = items.iter().map(|i| format!("- {}\n", i.file_name)).collect();
format!("[ Staging {} ({}/10) ]\n\n{}", type_label, items.len(), list)
};
let keyboard = InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback("[ Confirm ]", "v1:stage:confirm"),
InlineKeyboardButton::callback("[ Cancel ]", "v1:stage:cancel"),
]]);
bot.send_message(chat_id, text)
.reply_markup(keyboard)
.await?;
Ok(())
}
async fn handle_staging_message(
bot: &Bot,
msg: Message,
dialogue: &BotDialogue,
ctx: &BotContext,
mut items: Vec<StagedItem>,
upload_type: UploadType,
) -> HandlerResult {
if items.len() >= ctx.config.upload_limits.max_batch_size {
bot.send_message(msg.chat.id, "Maximum batch size reached.").await?;
return Ok(());
}
let mut new_item = None;
match upload_type {
UploadType::Media => {
if let Some(photo) = msg.photo() {
let largest = photo.iter().max_by_key(|p| p.file.size);
if let Some(p) = largest {
new_item = Some(StagedItem {
file_id: p.file.id.clone(),
file_name: format!("photo_{}.jpg", items.len()),
mime_type: "image/jpeg".to_string(),
size: p.file.size as u64,
caption: msg.caption().map(|s| s.to_string()),
});
}
} else if let Some(video) = msg.video() {
new_item = Some(StagedItem {
file_id: video.file.id.clone(),
file_name: video.file_name.clone().unwrap_or_else(|| format!("video_{}.mp4", items.len())),
mime_type: "video/mp4".to_string(),
size: video.file.size as u64,
caption: msg.caption().map(|s| s.to_string()),
});
} else if let Some(audio) = msg.audio() {
new_item = Some(StagedItem {
file_id: audio.file.id.clone(),
file_name: audio.file_name.clone().unwrap_or_else(|| format!("audio_{}.mp3", items.len())),
mime_type: "audio/mpeg".to_string(),
size: audio.file.size as u64,
caption: msg.caption().map(|s| s.to_string()),
});
}
}
UploadType::Document => {
if let Some(doc) = msg.document() {
new_item = Some(StagedItem {
file_id: doc.file.id.clone(),
file_name: doc.file_name.clone().unwrap_or_else(|| format!("file_{}", items.len())),
mime_type: doc.mime_type.clone().map(|m| m.to_string()).unwrap_or_else(|| "application/octet-stream".to_string()),
size: doc.file.size as u64,
caption: msg.caption().map(|s| s.to_string()),
});
}
}
UploadType::Text => {
if let Some(text) = msg.text() {
if !text.starts_with('/') {
new_item = Some(StagedItem {
file_id: format!("text://{}", msg.id.0),
file_name: "text.txt".to_string(),
mime_type: "text/plain".to_string(),
size: text.len() as u64,
caption: Some(text.to_string()),
});
}
}
}
}
if let Some(item) = new_item {
items.push(item);
dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type }).await?;
send_staging_message(bot, msg.chat.id, &items, upload_type).await?;
}
Ok(())
}
async fn refresh_options_message(
bot: &Bot,
chat_id: ChatId,
_items: &[StagedItem],
options: &UploadOptions,
) -> HandlerResult {
let destroy_text = match options.max_views {
Some(n) => format!("Auto-destroy: {} views", n),
None => "Auto-destroy: Off".to_string(),
};
let download_text = if options.allow_download {
"Allow download: Yes"
} else {
"Allow download: No"
};
let password_text = if options.password.is_some() {
"Password: Set"
} else {
"Password: None"
};
let text = format!(
"[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.",
destroy_text, download_text, password_text
);
let keyboard = InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("[ Toggle Destroy ]", "v1:opt:toggle_destroy"),
InlineKeyboardButton::callback("[ Toggle Download ]", "v1:opt:toggle_download"),
],
vec![
InlineKeyboardButton::callback("[ Set Password ]", "v1:opt:set_password"),
],
vec![
InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"),
InlineKeyboardButton::callback("[ Confirm & Upload ]", "v1:opt:confirm_final"),
],
]);
bot.send_message(chat_id, text)
.reply_markup(keyboard)
.await?;
Ok(())
}
async fn finalize_upload(
bot: &Bot,
chat_id: ChatId,
user_id: i64,
items: Vec<StagedItem>,
options: UploadOptions,
dialogue: &BotDialogue,
ctx: &BotContext,
) -> HandlerResult {
let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]").await?;
let total_size: u64 = items.iter().map(|i| i.size).sum();
if total_size > ctx.config.upload_limits.max_total_batch_bytes {
bot.edit_message_text(chat_id, status_msg.id, "[ Error: total batch size exceeds limit. ]").await?;
dialogue.update(BotState::MainMenu).await?;
return Ok(());
}
// Check available disk space in temp dir
if let Ok(temp_path) = std::fs::canonicalize(&ctx.config.storage.paths.temp) {
if let Ok(info) = fs2::available_space(&temp_path) {
if info < total_size * 2 {
bot.edit_message_text(chat_id, status_msg.id, "[ Error: insufficient storage space. ]").await?;
dialogue.update(BotState::MainMenu).await?;
return Ok(());
}
}
}
let content_id = ContentId::generate();
let repo = ContentRepo::new(ctx.db.conn());
let mut attempts = 0;
while repo.get(&content_id).await?.is_some() && attempts < 5 {
attempts += 1;
}
let password_hash = options.password.as_ref().map(|p| {
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
use rand::rngs::OsRng;
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2.hash_password(p.as_bytes(), &salt)
.map(|h| h.to_string())
.unwrap_or_default()
});
ctx.pipeline.create_content_entry(
content_id.clone(),
user_id,
options.max_views,
options.allow_download,
password_hash,
).await?;
for (idx, item) in items.iter().enumerate() {
let result = if item.file_id.starts_with("text://") {
let data = item.caption.clone().unwrap_or_default().into_bytes();
let mut cursor = std::io::Cursor::new(data);
ctx.pipeline.ingest_file(
&content_id,
idx as u32,
&mut cursor,
&item.file_name,
&ctx.master_key,
&ctx.sem,
).await
} else {
match bot.get_file(&item.file_id).await {
Ok(file) => {
let mut data = Vec::new();
if let Err(e) = bot.download_file(&file.path, &mut data).await {
warn!("Download error: {}", e);
continue;
}
let mut cursor = std::io::Cursor::new(data);
ctx.pipeline.ingest_file(
&content_id,
idx as u32,
&mut cursor,
&item.file_name,
&ctx.master_key,
&ctx.sem,
).await
}
Err(e) => {
warn!("Get file error: {}", e);
continue;
}
}
};
if let Err(e) = result {
warn!("Ingest error: {}", e);
}
}
ctx.pipeline.activate_content(&content_id).await?;
let base_url = &ctx.config.server.base_url;
let link = format!("{}/?cxid={}", base_url, content_id.as_str());
let mut attrs = vec![];
if let Some(v) = options.max_views {
attrs.push(format!("auto-burn after {}", v));
}
if options.allow_download {
attrs.push("download allowed".to_string());
}
if options.password.is_some() {
attrs.push("password set".to_string());
}
let attr_text = if attrs.is_empty() {
"no special options".to_string()
} else {
attrs.join(", ")
};
let result_text = format!(
"[ Upload Complete ]\n\nLink: <code>{}</code>\n\nFiles: {} | {}",
link, items.len(), attr_text
);
bot.edit_message_text(chat_id, status_msg.id, result_text)
.parse_mode(ParseMode::Html)
.await?;
dialogue.update(BotState::MainMenu).await?;
Ok(())
}
async fn show_previous_uploads(
bot: &Bot,
chat_id: ChatId,
user_id: i64,
page: usize,
ctx: &BotContext,
) -> HandlerResult {
let repo = ContentRepo::new(ctx.db.conn());
let total = repo.count_by_user(user_id).await?;
let items = repo.list_by_user(user_id, 10, page * 10).await?;
let total_pages = (total + 9) / 10;
if items.is_empty() {
bot.send_message(chat_id, "You have no uploads.").await?;
return Ok(());
}
let base_url = &ctx.config.server.base_url;
let mut text = format!("[ Your Uploads ] Page {}/{}\n\n", page + 1, total_pages.max(1));
for content in &items {
let file_repo = ContentFileRepo::new(ctx.db.conn());
let files = file_repo.list_by_content(&content.id).await?;
let mut attrs = vec![];
if let Some(v) = content.max_views {
attrs.push(format!("auto-burn after {}", v));
}
if content.allow_download {
attrs.push("download allowed".to_string());
}
if content.password_hash.is_some() {
attrs.push("password set".to_string());
}
let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") };
text.push_str(&format!(
"- <code>{}</code> ({} files) [{}]\n {}?cxid={}\n\n",
content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str()
));
}
let mut buttons = vec![];
if page > 0 {
buttons.push(InlineKeyboardButton::callback("<<", format!("v1:prev:page:{}", page - 1)));
}
buttons.push(InlineKeyboardButton::callback(format!("Page {}/{}", page + 1, total_pages.max(1)), "noop"));
if page + 1 < total_pages {
buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1)));
}
let keyboard = InlineKeyboardMarkup::new(vec![buttons, vec![
InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"),
]]);
bot.send_message(chat_id, text)
.parse_mode(ParseMode::Html)
.reply_markup(keyboard)
.await?;
Ok(())
}
async fn handle_report(
bot: &Bot,
chat_id: ChatId,
reporter_id: i64,
text: &str,
dialogue: &BotDialogue,
ctx: &BotContext,
) -> HandlerResult {
let cxid = extract_cxid(text).ok_or("Invalid content ID or link")?;
let content_id = ContentId::try_from(cxid.as_str())?;
let repo = ContentRepo::new(ctx.db.conn());
let content = repo.get(&content_id).await?.ok_or("Content not found")?;
let report_repo = ReportRepo::new(ctx.db.conn());
let report_id = report_repo.insert(&content_id, reporter_id, text).await?;
for &group_id in &ctx.config.groups.review_group_ids {
let report_text = format!(
"[ NEW REPORT ] #{}\n\nCXID: <code>{}</code>\nReporter: <code>{}</code>\nOwner: <code>{}</code>\nUploaded: {}\nFiles: {}",
report_id,
cxid,
reporter_id,
content.user_id,
content.created_at.format("%Y-%m-%d %H:%M"),
1
);
let keyboard = InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("[ Delete + Blacklist ]", format!("v1:admin:delblk:{}", report_id)),
InlineKeyboardButton::callback("[ Delete Only ]", format!("v1:admin:del:{}", report_id)),
],
vec![
InlineKeyboardButton::callback("[ Blacklist Only ]", format!("v1:admin:blk:{}", report_id)),
InlineKeyboardButton::callback("[ Ignore ]", format!("v1:admin:ign:{}", report_id)),
],
]);
bot.send_message(ChatId(group_id), report_text)
.parse_mode(ParseMode::Html)
.reply_markup(keyboard)
.await
.ok();
}
bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.").await?;
dialogue.update(BotState::MainMenu).await?;
Ok(())
}
async fn handle_admin_callback(
bot: &Bot,
chat_id: ChatId,
user_id: i64,
parts: &[&str],
ctx: &BotContext,
) -> HandlerResult {
if !is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await {
bot.send_message(chat_id, "Unauthorized.").await?;
return Ok(());
}
let report_id = parts[3].parse::<i64>().unwrap_or(0);
let report_repo = ReportRepo::new(ctx.db.conn());
let report = match report_repo.get(report_id).await? {
Some(r) => r,
None => {
bot.send_message(chat_id, "Report not found.").await?;
return Ok(());
}
};
let content_repo = ContentRepo::new(ctx.db.conn());
let content = match content_repo.get(&report.content_id).await? {
Some(c) => c,
None => {
bot.send_message(chat_id, "Content not found.").await?;
return Ok(());
}
};
match parts[2] {
"delblk" => {
ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok();
content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok();
ctx.moderation.blacklist(content.user_id).await.ok();
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.set_role(content.user_id, "banned").await.ok();
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok();
bot.send_message(chat_id, format!("Deleted content {} and blacklisted user {}", report.content_id.as_str(), content.user_id))
.parse_mode(ParseMode::Html).await?;
}
"del" => {
ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok();
content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok();
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok();
bot.send_message(chat_id, format!("Deleted content {}", report.content_id.as_str()))
.parse_mode(ParseMode::Html).await?;
}
"blk" => {
ctx.moderation.blacklist(content.user_id).await.ok();
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.set_role(content.user_id, "banned").await.ok();
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok();
bot.send_message(chat_id, format!("Blacklisted user {}", content.user_id))
.parse_mode(ParseMode::Html).await?;
}
"ign" => {
report_repo.resolve(report_id, ReportStatus::Dismissed, user_id).await.ok();
bot.send_message(chat_id, format!("Ignored report #{}", report_id))
.parse_mode(ParseMode::Html).await?;
}
_ => {}
}
Ok(())
}
fn extract_cxid(input: &str) -> Option<String> {
let re = regex::Regex::new(r"[?&]cxid=([a-zA-Z0-9]{12})").ok()?;
if let Some(cap) = re.captures(input) {
return cap.get(1).map(|m| m.as_str().to_string());
}
let trimmed = input.trim();
if trimmed.len() == 12 && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) {
return Some(trimmed.to_string());
}
None
}
async fn is_admin(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool {
match bot.get_chat_member(chat_id, user_id).await {
Ok(member) => {
matches!(member.status(), ChatMemberStatus::Administrator | ChatMemberStatus::Owner)
}
Err(_) => false,
}
}
async fn is_admin_in_chat(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool {
is_admin(bot, chat_id, user_id).await
}
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::<i64>().ok());
if let Some(uid) = uid {
ctx.moderation.blacklist(uid).await?;
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.set_role(uid, "banned").await?;
bot.send_message(chat_id, format!("Blacklisted UID <code>{}</code>", uid))
.parse_mode(ParseMode::Html).await?;
}
Ok(())
}
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::<i64>().ok());
if let Some(uid) = uid {
ctx.moderation.remove_blacklist(uid).await?;
let user_repo = UserRepo::new(ctx.db.conn());
user_repo.set_role(uid, "user").await?;
bot.send_message(chat_id, format!("Whitelisted UID <code>{}</code>", uid))
.parse_mode(ParseMode::Html).await?;
}
Ok(())
}

View File

@@ -0,0 +1,11 @@
[package]
name = "cgcx-config"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
serde = { version = "1.0", features = ["derive"] }
config = "0.14"
tokio = { version = "1", features = ["fs", "sync", "time"] }
tracing = "0.1"

View File

@@ -0,0 +1,192 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub content: ContentConfig,
pub crypto: CryptoConfig,
pub telegram: TelegramConfig,
pub groups: GroupsConfig,
pub storage: StorageConfig,
pub upload_limits: UploadLimits,
pub server: ServerConfig,
pub rate_limiting: RateLimitConfig,
pub logging: LoggingConfig,
pub frontend: FrontendConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ContentConfig {
pub keep_content: bool,
pub share_mode: ShareMode,
pub default_allow_download: bool,
#[serde(default)]
pub default_max_views: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ShareMode {
B,
W,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CryptoConfig {
pub aes_master_key_source: KeySource,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum KeySource {
File { path: PathBuf },
Env { var: String },
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub api_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GroupsConfig {
pub admin_group_ids: Vec<i64>,
pub review_group_ids: Vec<i64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StorageConfig {
pub paths: StoragePaths,
pub chunk_size_bytes: usize,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StoragePaths {
pub media: PathBuf,
pub documents: PathBuf,
pub text: PathBuf,
pub temp: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UploadLimits {
pub max_batch_size: usize,
pub max_file_size_bytes: u64,
pub max_total_batch_bytes: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
pub base_url: String,
pub bind_address: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RateLimitConfig {
pub requests_per_minute: u32,
pub burst: u32,
pub password_attempts_per_minute: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
pub level: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FrontendConfig {
pub behavior_toggles: HashMap<String, bool>,
}
impl Config {
pub fn load() -> Result<Self, cgcx_core::CgcxError> {
let s = config::Config::builder()
.add_source(config::File::with_name("config/default"))
.add_source(config::File::with_name("config/local").required(false))
.add_source(
config::Environment::with_prefix("CGCX")
.separator("__")
.try_parsing(true)
.list_separator(","),
)
.build()
.map_err(|e| cgcx_core::CgcxError::Config(e.to_string()))?;
let cfg: Config = s.try_deserialize()
.map_err(|e| cgcx_core::CgcxError::Config(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<(), cgcx_core::CgcxError> {
let chunk = self.storage.chunk_size_bytes;
const MIN: usize = 8 * 1024 * 1024;
const MAX: usize = 256 * 1024 * 1024;
if chunk < MIN || chunk > MAX {
return Err(cgcx_core::CgcxError::Config(format!(
"chunk_size_bytes must be between {} and {}, got {}",
MIN, MAX, chunk
)));
}
if self.telegram.bot_token.is_empty() || self.telegram.bot_token == "BOT_TOKEN_PLACEHOLDER" {
return Err(cgcx_core::CgcxError::Config(
"telegram.bot_token must be set to a valid bot token".into()
));
}
if self.server.port == 0 {
return Err(cgcx_core::CgcxError::Config(
"server.port must be > 0".into()
));
}
if self.server.base_url.is_empty() {
return Err(cgcx_core::CgcxError::Config(
"server.base_url must be set".into()
));
}
if self.upload_limits.max_batch_size == 0
|| self.upload_limits.max_file_size_bytes == 0
|| self.upload_limits.max_total_batch_bytes == 0
{
return Err(cgcx_core::CgcxError::Config(
"upload_limits must all be > 0".into()
));
}
if self.rate_limiting.requests_per_minute == 0
|| self.rate_limiting.burst == 0
|| self.rate_limiting.password_attempts_per_minute == 0
{
return Err(cgcx_core::CgcxError::Config(
"rate_limiting values must all be > 0".into()
));
}
if self.logging.level.is_empty() {
return Err(cgcx_core::CgcxError::Config(
"logging.level must be set".into()
));
}
match &self.crypto.aes_master_key_source {
KeySource::File { path } if path.as_os_str().is_empty() => {
return Err(cgcx_core::CgcxError::Config(
"crypto.aes_master_key_source.file path must not be empty".into()
));
}
KeySource::Env { var } if var.is_empty() => {
return Err(cgcx_core::CgcxError::Config(
"crypto.aes_master_key_source.env var must not be empty".into()
));
}
_ => {}
}
Ok(())
}
}

View File

@@ -0,0 +1,9 @@
[package]
name = "cgcx-content-typing"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
infer = "0.16"
mime_guess = "2"

View File

@@ -0,0 +1,87 @@
pub const RENDER_IMAGE: u32 = 1 << 0;
pub const RENDER_VIDEO: u32 = 1 << 1;
pub const RENDER_AUDIO: u32 = 1 << 2;
pub const RENDER_MARKDOWN: u32 = 1 << 3;
pub const RENDER_TEXT: u32 = 1 << 4;
pub const RENDER_DOCUMENT: u32 = 1 << 5;
pub const RENDER_EXECUTABLE: u32 = 1 << 6;
pub const RENDER_DANGEROUS: u32 = 1 << 7;
pub const RENDER_NO_INLINE: u32 = 1 << 8;
const DANGEROUS_EXTENSIONS: &[&str] = &[
"exe", "scr", "bat", "cmd", "sh", "dll", "so", "dylib", "jar", "msi", "com", "app", "apk",
];
const DANGEROUS_MIME_TYPES: &[&str] = &[
"text/html",
"text/javascript",
"text/css",
"application/javascript",
"application/ecmascript",
];
pub fn detect_mime_type(data: &[u8], file_name: &str) -> String {
if let Some(kind) = infer::get(data) {
let mime = kind.mime_type();
if !mime.is_empty() && mime != "application/octet-stream" {
return mime.to_string();
}
}
mime_guess::from_path(file_name)
.first_or_octet_stream()
.to_string()
}
pub fn compute_render_flags(mime_type: &str, file_name: &str, data: &[u8]) -> u32 {
let mut flags = 0u32;
if mime_type.starts_with("image/") {
flags |= RENDER_IMAGE;
} else if mime_type.starts_with("video/") {
flags |= RENDER_VIDEO;
} else if mime_type.starts_with("audio/") {
flags |= RENDER_AUDIO;
} else if mime_type == "text/markdown"
|| file_name.ends_with(".md")
|| file_name.ends_with(".markdown")
{
flags |= RENDER_MARKDOWN | RENDER_TEXT;
} else if mime_type.starts_with("text/") {
flags |= RENDER_TEXT;
} else if mime_type == "application/pdf" || mime_type.starts_with("application/vnd.") {
flags |= RENDER_DOCUMENT;
}
if DANGEROUS_MIME_TYPES.contains(&mime_type) {
flags |= RENDER_DANGEROUS | RENDER_NO_INLINE;
}
let ext = file_name.rsplit('.').next().unwrap_or("").to_lowercase();
if DANGEROUS_EXTENSIONS.contains(&ext.as_str()) {
flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE;
}
if let Some(kind) = infer::get(data) {
let mime = kind.mime_type();
if mime == "application/x-executable"
|| mime == "application/x-msdownload"
|| mime == "application/x-pie-executable"
{
flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE;
}
}
if flags & (RENDER_EXECUTABLE | RENDER_DANGEROUS) != 0 {
flags |= RENDER_NO_INLINE;
}
flags
}
pub fn is_dangerous(flags: u32) -> bool {
flags & RENDER_DANGEROUS != 0
}
pub fn should_inline(flags: u32) -> bool {
flags & RENDER_NO_INLINE == 0
}

View File

@@ -0,0 +1,10 @@
[package]
name = "cgcx-core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"

View File

@@ -0,0 +1,75 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::fmt;
const CXID_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const CXID_LENGTH: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub struct ContentId(String);
impl ContentId {
pub fn generate() -> Self {
let mut rng = rand::thread_rng();
let alphabet_len = CXID_ALPHABET.len() as u32;
let mut s = String::with_capacity(CXID_LENGTH);
while s.len() < CXID_LENGTH {
let val: u32 = rng.gen();
// Rejection sampling: only use values that map uniformly
let max = u32::MAX - (u32::MAX % alphabet_len);
if val < max {
s.push(CXID_ALPHABET[(val % alphabet_len) as usize] as char);
}
}
Self(s)
}
pub fn is_valid(s: &str) -> bool {
s.len() == CXID_LENGTH
&& s.bytes().all(|b| CXID_ALPHABET.contains(&b))
}
pub fn as_str(&self) -> &str {
&self.0
}
/// Construct from a string without validation.
/// Only use when the source is trusted (e.g., DB row with FK constraint).
pub fn new_unchecked(s: String) -> Self {
Self(s)
}
}
impl fmt::Display for ContentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for ContentId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for ContentId {
type Error = crate::CgcxError;
fn try_from(value: String) -> crate::Result<Self> {
if Self::is_valid(&value) {
Ok(Self(value))
} else {
Err(crate::CgcxError::InvalidContentId(value))
}
}
}
impl TryFrom<&str> for ContentId {
type Error = crate::CgcxError;
fn try_from(value: &str) -> crate::Result<Self> {
if Self::is_valid(value) {
Ok(Self(value.to_string()))
} else {
Err(crate::CgcxError::InvalidContentId(value.to_string()))
}
}
}

View File

@@ -0,0 +1,37 @@
pub mod id;
pub mod models;
pub use id::ContentId;
pub use models::*;
#[derive(thiserror::Error, Debug)]
pub enum CgcxError {
#[error("invalid content id: {0}")]
InvalidContentId(String),
#[error("crypto error: {0}")]
Crypto(String),
#[error("database error: {0}")]
Database(String),
#[error("storage error: {0}")]
Storage(String),
#[error("config error: {0}")]
Config(String),
#[error("moderation error: {0}")]
Moderation(String),
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("rate limited")]
RateLimited,
#[error("bad request: {0}")]
BadRequest(String),
#[error("insufficient storage")]
InsufficientStorage,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, CgcxError>;

View File

@@ -0,0 +1,86 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::id::ContentId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum UserRole {
User,
Admin,
Banned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum ContentStatus {
Staged,
Active,
Deleted,
Blacklisted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum ReportStatus {
Open,
Dismissed,
Actioned,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct User {
pub id: i64,
pub telegram_username: Option<String>,
pub first_name: String,
pub role: UserRole,
pub accepted_terms_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Content {
pub id: ContentId,
pub user_id: i64,
pub status: ContentStatus,
pub view_count: u64,
pub max_views: Option<u64>,
pub allow_download: bool,
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ContentFile {
pub content_id: ContentId,
pub file_index: u32,
pub original_name: String,
pub stored_path: std::path::PathBuf,
pub mime_type: String,
pub size_bytes: u64,
pub ciphertext_size_bytes: u64,
pub encrypted_key_wrapped: Vec<u8>,
pub encrypted_hash: Vec<u8>,
pub render_flags: u32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Report {
pub id: i64,
pub content_id: ContentId,
pub reporter_user_id: i64,
pub reason: String,
pub status: ReportStatus,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub resolver_id: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AdminAction {
pub id: i64,
pub admin_user_id: i64,
pub target_type: String,
pub target_id: String,
pub action: String,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,14 @@
[package]
name = "cgcx-crypto"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
rand = "0.8"
blake3 = "1.5"
sodiumoxide = "0.2"
aes-kw = "0.2"
aes = "0.8"
hex = "0.4"
tracing = "0.1"

View File

@@ -0,0 +1,107 @@
use blake3::Hasher;
use sodiumoxide::crypto::secretstream::xchacha20poly1305;
use std::path::Path;
pub mod master_key;
pub use master_key::MasterKey;
const KEY_WRAP_VERSION: u8 = 0x01;
#[derive(Debug, Clone)]
pub struct ContentKey {
pub key: xchacha20poly1305::Key,
}
impl ContentKey {
pub fn generate() -> Self {
let key = xchacha20poly1305::gen_key();
Self { key }
}
}
pub fn wrap_content_key(content_key: &xchacha20poly1305::Key, master_key: &MasterKey) -> Vec<u8> {
let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into();
let key_bytes = content_key.as_ref();
let mut wrapped = vec![0u8; key_bytes.len() + 8];
kek.wrap(key_bytes, &mut wrapped).expect("AES-KW wrap failed");
let mut out = vec![KEY_WRAP_VERSION];
out.extend_from_slice(&wrapped);
out
}
pub fn unwrap_content_key(wrapped: &[u8], master_key: &MasterKey) -> cgcx_core::Result<xchacha20poly1305::Key> {
if wrapped.is_empty() || wrapped[0] != KEY_WRAP_VERSION {
return Err(cgcx_core::CgcxError::Crypto("unsupported key wrap version".into()));
}
let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into();
let wrapped_key = &wrapped[1..];
let mut unwrapped = vec![0u8; wrapped_key.len().saturating_sub(8)];
kek.unwrap(wrapped_key, &mut unwrapped)
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("AES-KW unwrap failed: {:?}", e)))?;
xchacha20poly1305::Key::from_slice(&unwrapped)
.ok_or_else(|| cgcx_core::CgcxError::Crypto("invalid unwrapped key length".into()))
}
pub struct EncryptStream {
stream: xchacha20poly1305::Stream<xchacha20poly1305::Push>,
hasher: Hasher,
header: xchacha20poly1305::Header,
}
impl EncryptStream {
pub fn new(key: &xchacha20poly1305::Key) -> Self {
let (stream, header) = xchacha20poly1305::Stream::init_push(key)
.expect("secretstream init_push failed");
let mut hasher = Hasher::new();
hasher.update(header.as_ref());
Self { stream, hasher, header }
}
pub fn header(&self) -> &xchacha20poly1305::Header {
&self.header
}
pub fn push(&mut self, plaintext: &[u8], tag: xchacha20poly1305::Tag) -> Vec<u8> {
let ciphertext = self.stream.push(plaintext, None, tag)
.expect("secretstream push failed");
self.hasher.update(&ciphertext);
ciphertext
}
pub fn finalize(self) -> [u8; 32] {
self.hasher.finalize().into()
}
}
pub struct DecryptStream {
stream: xchacha20poly1305::Stream<xchacha20poly1305::Pull>,
hasher: Hasher,
}
impl DecryptStream {
pub fn new(key: &xchacha20poly1305::Key, header: &xchacha20poly1305::Header) -> cgcx_core::Result<Self> {
let stream = xchacha20poly1305::Stream::init_pull(header, key)
.map_err(|_| cgcx_core::CgcxError::Crypto("secretstream init_pull failed".into()))?;
let mut hasher = Hasher::new();
hasher.update(header.as_ref());
Ok(Self { stream, hasher })
}
pub fn pull(&mut self, ciphertext: &[u8]) -> cgcx_core::Result<(Vec<u8>, xchacha20poly1305::Tag)> {
let result = self.stream.pull(ciphertext, None)
.map_err(|_| cgcx_core::CgcxError::Crypto("secretstream pull failed (tampered data?)".into()))?;
self.hasher.update(ciphertext);
Ok(result)
}
pub fn finalize(self) -> [u8; 32] {
self.hasher.finalize().into()
}
}
pub fn hash_file_at_path(path: &Path) -> cgcx_core::Result<[u8; 32]> {
let mut hasher = Hasher::new();
let mut file = std::fs::File::open(path)?;
std::io::copy(&mut file, &mut hasher)?;
Ok(hasher.finalize().into())
}

View File

@@ -0,0 +1,93 @@
use blake3::Hasher;
use rand::RngCore;
use std::path::Path;
use tracing::{info, trace, warn};
pub struct MasterKey([u8; 32]);
impl MasterKey {
pub fn generate() -> Self {
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
Self(key)
}
pub fn from_hex(hex_str: &str) -> cgcx_core::Result<Self> {
let bytes = hex::decode(hex_str.trim())
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("invalid master key hex: {}", e)))?;
if bytes.len() != 32 {
return Err(cgcx_core::CgcxError::Crypto(format!(
"master key must be 32 bytes (64 hex chars), got {} bytes",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Self(key))
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn fingerprint(&self) -> String {
let hash = Hasher::new().update(&self.0).finalize();
hex::encode(&hash.as_bytes()[..8])
}
pub fn load_from_env(var: &str) -> cgcx_core::Result<Self> {
sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?;
match std::env::var(var) {
Ok(val) => {
let key = Self::from_hex(&val)?;
info!("Master key loaded from env var {}", var);
Ok(key)
}
Err(_) => {
let key = Self::generate();
warn!(
"Env var {} not set. A new master key has been generated.\n\
SAVE THIS KEY IMMEDIATELY (64 hex chars):\n{}\n\
Set it as {}=<key> to persist across restarts.",
var, key.to_hex(), var
);
Ok(key)
}
}
}
pub fn load_from_file(path: &Path) -> cgcx_core::Result<Self> {
sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?;
if path.exists() {
let val = std::fs::read_to_string(path)
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("read key file: {}", e)))?;
let key = Self::from_hex(&val)?;
info!("Master key loaded from file {:?}", path);
Ok(key)
} else {
let key = Self::generate();
std::fs::write(path, key.to_hex())
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("write key file: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms).ok();
}
warn!("Generated new master key and wrote to {:?}", path);
Ok(key)
}
}
pub fn log_startup(&self, debug_log_keys: bool) {
info!("Storage master key loaded. fingerprint={}", self.fingerprint());
if debug_log_keys {
trace!("Master key full hex: {}", self.to_hex());
}
}
}

13
crates/cgcx-db/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "cgcx-db"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-config = { path = "../cgcx-config" }
chrono = { version = "0.4", features = ["serde"] }
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
rusqlite_migration = "1.3"
tokio = { version = "1", features = ["sync", "rt"] }
tracing = "0.1"

55
crates/cgcx-db/src/lib.rs Normal file
View File

@@ -0,0 +1,55 @@
use cgcx_core::{Result, CgcxError};
use rusqlite::Connection;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
pub mod repos;
pub use repos::*;
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
}
impl Database {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let conn = Connection::open(path).map_err(|e| CgcxError::Database(e.to_string()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;"
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory().map_err(|e| CgcxError::Database(e.to_string()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;"
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
pub fn conn(&self) -> Arc<Mutex<Connection>> {
self.conn.clone()
}
pub async fn run_migrations(&self) -> Result<()> {
let mut conn = self.conn.lock().await;
let migrations = rusqlite_migration::Migrations::new(vec![
rusqlite_migration::M::up(include_str!("../../../migrations/001_init.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/002_indexes.sql")),
]);
migrations.to_latest(&mut *conn)
.map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?;
Ok(())
}
}

389
crates/cgcx-db/src/repos.rs Normal file
View File

@@ -0,0 +1,389 @@
use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, Report, ReportStatus, Result, CgcxError, User};
use rusqlite::{params, OptionalExtension};
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct UserRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl UserRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn ensure_exists(&self, id: i64, username: Option<&str>, first_name: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO users (id, telegram_username, first_name) VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET telegram_username=excluded.telegram_username, first_name=excluded.first_name",
params![id, username, first_name],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn get(&self, id: i64) -> Result<Option<User>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, telegram_username, first_name, role, accepted_terms_at, created_at FROM users WHERE id = ?1",
params![id],
|row| {
let role: String = row.get(3)?;
Ok(User {
id: row.get(0)?,
telegram_username: row.get(1)?,
first_name: row.get(2)?,
role: match role.as_str() {
"admin" => cgcx_core::UserRole::Admin,
"banned" => cgcx_core::UserRole::Banned,
_ => cgcx_core::UserRole::User,
},
accepted_terms_at: row.get(4)?,
created_at: row.get(5)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn set_role(&self, id: i64, role: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE users SET role = ?1 WHERE id = ?2",
params![role, id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn set_accepted_terms(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE users SET accepted_terms_at = datetime('now') WHERE id = ?1",
params![id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
}
pub struct ContentRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ContentRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, content: &Content) -> Result<()> {
let conn = self.conn.lock().await;
let status = format!("{:?}", content.status).to_lowercase();
conn.execute(
"INSERT INTO contents (id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO NOTHING",
params![
content.id.as_str(),
content.user_id,
status,
content.view_count as i64,
content.max_views.map(|v| v as i64),
content.allow_download as i64,
content.password_hash.as_ref(),
content.created_at,
content.deleted_at,
],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn get(&self, id: &ContentId) -> Result<Option<Content>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at
FROM contents WHERE id = ?1",
params![id.as_str()],
|row| {
let status: String = row.get(2)?;
Ok(Content {
id: ContentId::new_unchecked(row.get(0)?),
user_id: row.get(1)?,
status: match status.as_str() {
"staged" => ContentStatus::Staged,
"deleted" => ContentStatus::Deleted,
"blacklisted" => ContentStatus::Blacklisted,
_ => ContentStatus::Active,
},
view_count: row.get::<_, i64>(3)? as u64,
max_views: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
allow_download: row.get::<_, i64>(5)? != 0,
password_hash: row.get(6)?,
created_at: row.get(7)?,
deleted_at: row.get(8)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn list_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Content>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at
FROM contents WHERE user_id = ?1 AND status != 'deleted' ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![user_id, limit as i64, offset as i64], |row| {
let status: String = row.get(2)?;
Ok(Content {
id: ContentId::new_unchecked(row.get(0)?),
user_id: row.get(1)?,
status: match status.as_str() {
"staged" => ContentStatus::Staged,
"deleted" => ContentStatus::Deleted,
"blacklisted" => ContentStatus::Blacklisted,
_ => ContentStatus::Active,
},
view_count: row.get::<_, i64>(3)? as u64,
max_views: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
allow_download: row.get::<_, i64>(5)? != 0,
password_hash: row.get(6)?,
created_at: row.get(7)?,
deleted_at: row.get(8)?,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
pub async fn count_by_user(&self, user_id: i64) -> Result<usize> {
let conn = self.conn.lock().await;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM contents WHERE user_id = ?1 AND status != 'deleted'",
params![user_id],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(count as usize)
}
pub async fn increment_views(&self, id: &ContentId) -> Result<u64> {
let conn = self.conn.lock().await;
let new: i64 = conn.query_row(
"UPDATE contents SET view_count = view_count + 1 WHERE id = ?1 RETURNING view_count",
params![id.as_str()],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(new as u64)
}
pub async fn set_status(&self, id: &ContentId, status: ContentStatus) -> Result<()> {
let conn = self.conn.lock().await;
let s = format!("{:?}", status).to_lowercase();
conn.execute(
"UPDATE contents SET status = ?1, deleted_at = CASE WHEN ?1 IN ('deleted','blacklisted') THEN datetime('now') ELSE NULL END WHERE id = ?2",
params![s, id.as_str()],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn delete_permanent(&self, id: &ContentId) -> Result<()> {
let mut conn = self.conn.lock().await;
let tx = conn.transaction().map_err(|e| CgcxError::Database(e.to_string()))?;
tx.execute("DELETE FROM content_files WHERE content_id = ?1", params![id.as_str()])
.map_err(|e| CgcxError::Database(e.to_string()))?;
tx.execute("DELETE FROM contents WHERE id = ?1", params![id.as_str()])
.map_err(|e| CgcxError::Database(e.to_string()))?;
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
}
pub struct ContentFileRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ContentFileRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, file: &ContentFile) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO content_files (content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(content_id, file_index) DO NOTHING",
params![
file.content_id.as_str(),
file.file_index as i64,
&file.original_name,
file.stored_path.to_str(),
&file.mime_type,
file.size_bytes as i64,
file.ciphertext_size_bytes as i64,
&file.encrypted_key_wrapped,
&file.encrypted_hash,
file.render_flags as i64,
file.created_at,
],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn list_by_content(&self, content_id: &ContentId) -> Result<Vec<ContentFile>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at
FROM content_files WHERE content_id = ?1 ORDER BY file_index"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![content_id.as_str()], |row| {
Ok(ContentFile {
content_id: ContentId::new_unchecked(row.get(0)?),
file_index: row.get::<_, i64>(1)? as u32,
original_name: row.get(2)?,
stored_path: std::path::PathBuf::from(row.get::<_, String>(3)?),
mime_type: row.get(4)?,
size_bytes: row.get::<_, i64>(5)? as u64,
ciphertext_size_bytes: row.get::<_, i64>(6)? as u64,
encrypted_key_wrapped: row.get(7)?,
encrypted_hash: row.get(8)?,
render_flags: row.get::<_, i64>(9)? as u32,
created_at: row.get(10)?,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
pub async fn find_orphan_files(&self) -> Result<Vec<String>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT cf.stored_path FROM content_files cf
JOIN contents c ON c.id = cf.content_id
WHERE c.status IN ('deleted', 'blacklisted')"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map([], |row| {
row.get::<_, String>(0)
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
}
pub struct ReportRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ReportRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, content_id: &ContentId, reporter_user_id: i64, reason: &str) -> Result<i64> {
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],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
pub async fn get(&self, id: i64) -> Result<Option<Report>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, content_id, reporter_user_id, reason, status, created_at, resolved_at, resolver_id
FROM reports WHERE id = ?1",
params![id],
|row| {
let status: String = row.get(4)?;
Ok(Report {
id: row.get(0)?,
content_id: ContentId::new_unchecked(row.get(1)?),
reporter_user_id: row.get(2)?,
reason: row.get(3)?,
status: match status.as_str() {
"dismissed" => ReportStatus::Dismissed,
"actioned" => ReportStatus::Actioned,
_ => ReportStatus::Open,
},
created_at: row.get(5)?,
resolved_at: row.get(6)?,
resolver_id: row.get(7)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn list(&self, limit: usize, offset: usize) -> Result<Vec<Report>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, content_id, reporter_user_id, reason, status, created_at, resolved_at, resolver_id
FROM reports ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![limit as i64, offset as i64], |row| {
let status: String = row.get(4)?;
Ok(Report {
id: row.get(0)?,
content_id: ContentId::new_unchecked(row.get(1)?),
reporter_user_id: row.get(2)?,
reason: row.get(3)?,
status: match status.as_str() {
"dismissed" => ReportStatus::Dismissed,
"actioned" => ReportStatus::Actioned,
_ => ReportStatus::Open,
},
created_at: row.get(5)?,
resolved_at: row.get(6)?,
resolver_id: row.get(7)?,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
pub async fn resolve(&self, id: i64, status: ReportStatus, resolver_id: i64) -> Result<()> {
let conn = self.conn.lock().await;
let s = format!("{:?}", status).to_lowercase();
conn.execute(
"UPDATE reports SET status = ?1, resolver_id = ?2, resolved_at = datetime('now') WHERE id = ?3",
params![s, resolver_id, id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
}
pub struct AdminActionRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl AdminActionRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, action: &AdminAction) -> Result<i64> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO admin_actions (admin_user_id, target_type, target_id, action)
VALUES (?1, ?2, ?3, ?4)",
params![
action.admin_user_id,
&action.target_type,
&action.target_id,
&action.action,
],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "cgcx-file-pipeline"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-crypto = { path = "../cgcx-crypto" }
cgcx-storage = { path = "../cgcx-storage" }
cgcx-content-typing = { path = "../cgcx-content-typing" }
cgcx-db = { path = "../cgcx-db" }
cgcx-config = { path = "../cgcx-config" }
tokio = { version = "1", features = ["fs", "io-util", "sync"] }
tempfile = "3"
tracing = "0.1"
chrono = "0.4"
sodiumoxide = "0.2"

View File

@@ -0,0 +1,285 @@
use cgcx_config::Config;
use cgcx_core::{ContentFile, ContentId, ContentStatus, Content, Result, CgcxError};
use cgcx_crypto::{ContentKey, wrap_content_key};
use cgcx_db::{Database, ContentRepo, ContentFileRepo};
use cgcx_storage::Storage;
use cgcx_content_typing::{detect_mime_type, compute_render_flags};
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::{Message, Final};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use std::collections::HashSet;
pub use cgcx_crypto::MasterKey;
pub struct FilePipeline {
storage: Storage,
db: Database,
config: Config,
}
impl FilePipeline {
pub fn new(storage: Storage, db: Database, config: Config) -> Self {
Self { storage, db, config }
}
pub async fn ingest_file(
&self,
content_id: &ContentId,
file_index: u32,
mut source: impl AsyncRead + Unpin,
original_name: &str,
master_key: &MasterKey,
sem: &tokio::sync::Semaphore,
) -> Result<ContentFile> {
let _permit = sem.acquire().await
.map_err(|e| CgcxError::Storage(format!("semaphore acquire failed: {}", e)))?;
let chunk_size = self.config.storage.chunk_size_bytes;
let mut buf = vec![0u8; chunk_size];
// Read first chunk for MIME detection
let n = source.read(&mut buf).await
.map_err(|e| CgcxError::Storage(format!("read failed: {}", e)))?;
if n == 0 {
return Err(CgcxError::BadRequest("empty file".into()));
}
let mime_type = detect_mime_type(&buf[..n], original_name);
let render_flags = compute_render_flags(&mime_type, original_name, &buf[..n]);
let content_key = ContentKey::generate();
let mut encrypt_stream = cgcx_crypto::EncryptStream::new(&content_key.key);
let header = encrypt_stream.header().clone();
let named_temp = self.storage.temp_file()?;
let temp_path = named_temp.path().to_path_buf();
let mut total_size: u64 = 0;
{
let mut temp_file = tokio::fs::File::create(&temp_path).await
.map_err(|e| CgcxError::Storage(format!("create temp file: {}", e)))?;
temp_file.write_all(header.as_ref()).await
.map_err(|e| CgcxError::Storage(format!("write header: {}", e)))?;
let mut pending = n;
loop {
if pending == chunk_size {
let new_total = total_size + pending as u64;
if new_total > self.config.upload_limits.max_file_size_bytes {
return Err(CgcxError::BadRequest(format!(
"file too large: {} > {}",
new_total, self.config.upload_limits.max_file_size_bytes
)));
}
total_size = new_total;
let ciphertext = encrypt_stream.push(&buf[..pending], Message);
temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await
.map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?;
temp_file.write_all(&ciphertext).await
.map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?;
pending = 0;
}
let read_n = source.read(&mut buf[pending..]).await
.map_err(|e| CgcxError::Storage(format!("read failed: {}", e)))?;
if read_n == 0 {
if pending > 0 {
let new_total = total_size + pending as u64;
if new_total > self.config.upload_limits.max_file_size_bytes {
return Err(CgcxError::BadRequest(format!(
"file too large: {} > {}",
new_total, self.config.upload_limits.max_file_size_bytes
)));
}
total_size = new_total;
let ciphertext = encrypt_stream.push(&buf[..pending], Final);
temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await
.map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?;
temp_file.write_all(&ciphertext).await
.map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?;
} else if total_size > 0 {
// File ended exactly on a chunk boundary; push empty final tag.
let ciphertext = encrypt_stream.push(&[], Final);
temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await
.map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?;
temp_file.write_all(&ciphertext).await
.map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?;
}
break;
}
pending += read_n;
}
temp_file.flush().await
.map_err(|e| CgcxError::Storage(format!("flush temp file: {}", e)))?;
}
let encrypted_hash = encrypt_stream.finalize();
let ciphertext_size_bytes = self.storage.file_size(&temp_path).await?;
let final_path = self.storage.file_path(content_id, file_index, &mime_type)?;
if let Some(parent) = final_path.parent() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| CgcxError::Storage(e.to_string()))?;
}
named_temp.persist(&final_path)
.map_err(|e| CgcxError::Storage(format!("persist failed: {}", e)))?;
let encrypted_key_wrapped = wrap_content_key(&content_key.key, master_key);
let content_file = ContentFile {
content_id: content_id.clone(),
file_index,
original_name: original_name.to_string(),
stored_path: final_path,
mime_type,
size_bytes: total_size,
ciphertext_size_bytes,
encrypted_key_wrapped,
encrypted_hash: encrypted_hash.to_vec(),
render_flags,
created_at: chrono::Utc::now(),
};
let file_repo = ContentFileRepo::new(self.db.conn());
file_repo.insert(&content_file).await?;
Ok(content_file)
}
pub async fn create_content_entry(
&self,
content_id: ContentId,
user_id: i64,
max_views: Option<u64>,
allow_download: bool,
password_hash: Option<String>,
) -> Result<()> {
let content = Content {
id: content_id,
user_id,
status: ContentStatus::Staged,
view_count: 0,
max_views,
allow_download,
password_hash,
created_at: chrono::Utc::now(),
deleted_at: None,
};
let repo = ContentRepo::new(self.db.conn());
repo.insert(&content).await
}
pub async fn activate_content(&self, content_id: &ContentId) -> Result<()> {
let repo = ContentRepo::new(self.db.conn());
repo.set_status(content_id, ContentStatus::Active).await
}
pub async fn delete_content(&self, content_id: &ContentId, keep_disk: bool) -> Result<()> {
let file_repo = ContentFileRepo::new(self.db.conn());
let files = file_repo.list_by_content(content_id).await?;
if !keep_disk {
for file in &files {
if let Err(e) = tokio::fs::remove_file(&file.stored_path).await {
tracing::warn!("failed to remove file {:?}: {}", file.stored_path, e);
}
}
if let Some(first) = files.first() {
let _ = self.storage.delete_content_files(content_id, &first.mime_type).await;
}
}
let repo = ContentRepo::new(self.db.conn());
repo.delete_permanent(content_id).await
}
pub async fn cleanup_orphans(&self) -> Result<()> {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 60 * 60);
// 1. Clean old temp files
let mut entries = tokio::fs::read_dir(self.storage.temp_dir()).await
.map_err(|e| CgcxError::Storage(format!("read temp dir: {}", e)))?;
while let Some(entry) = entries.next_entry().await
.map_err(|e| CgcxError::Storage(format!("read temp dir entry: {}", e)))?
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("tmp") {
if let Ok(meta) = entry.metadata().await {
if let Ok(modified) = meta.modified() {
if modified < cutoff {
if let Err(e) = tokio::fs::remove_file(&path).await {
tracing::warn!("failed to remove orphan temp file {:?}: {}", path, e);
} else {
tracing::info!("removed orphan temp file: {:?}", path);
}
}
}
}
}
}
// 2. Clean unreferenced .enc files in storage dirs
let file_repo = ContentFileRepo::new(self.db.conn());
for root in [self.storage.media_dir(), self.storage.documents_dir(), self.storage.text_dir()] {
let mut entries = match tokio::fs::read_dir(root).await {
Ok(e) => e,
Err(e) => {
tracing::warn!("failed to read storage dir {:?}: {}", root, e);
continue;
}
};
while let Some(entry) = entries.next_entry().await
.map_err(|e| CgcxError::Storage(format!("read storage dir entry: {}", e)))?
{
let dir_path = entry.path();
if !dir_path.is_dir() {
continue;
}
let content_id_str = dir_path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let db_paths: HashSet<std::path::PathBuf> = if ContentId::is_valid(content_id_str) {
let content_id = ContentId::new_unchecked(content_id_str.to_string());
match file_repo.list_by_content(&content_id).await {
Ok(files) => files.into_iter().map(|f| f.stored_path).collect(),
Err(e) => {
tracing::warn!("failed to list files for {}: {}", content_id, e);
continue;
}
}
} else {
// Invalid content directory nothing in it can be referenced.
HashSet::new()
};
let mut sub_entries = tokio::fs::read_dir(&dir_path).await
.map_err(|e| CgcxError::Storage(format!("read content dir: {}", e)))?;
while let Some(sub_entry) = sub_entries.next_entry().await
.map_err(|e| CgcxError::Storage(format!("read content dir entry: {}", e)))?
{
let path = sub_entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("enc") {
if !db_paths.contains(&path) {
if let Err(e) = tokio::fs::remove_file(&path).await {
tracing::warn!("failed to remove orphan enc file {:?}: {}", path, e);
} else {
tracing::info!("removed orphan enc file: {:?}", path);
}
}
}
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "cgcx-moderation"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-config = { path = "../cgcx-config" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["fs", "sync", "time", "rt"] }
tracing = "0.1"
chrono = "0.4"

View File

@@ -0,0 +1,152 @@
use cgcx_config::{Config, ShareMode};
use cgcx_core::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{info, warn};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ModerationLists {
pub blacklisted: HashSet<i64>,
pub whitelisted: HashSet<i64>,
}
pub struct ModerationEngine {
lists: Arc<std::sync::RwLock<ModerationLists>>,
share_mode: ShareMode,
blacklist_path: PathBuf,
whitelist_path: PathBuf,
}
impl ModerationEngine {
pub fn new(config: &Config, base_data_dir: PathBuf) -> Self {
Self {
lists: Arc::new(std::sync::RwLock::new(ModerationLists::default())),
share_mode: config.content.share_mode.clone(),
blacklist_path: base_data_dir.join("blacklisted_ids.json"),
whitelist_path: base_data_dir.join("whitelisted_ids.json"),
}
}
pub async fn load(&self) -> Result<()> {
let blacklisted = load_id_set(&self.blacklist_path).await?;
let whitelisted = load_id_set(&self.whitelist_path).await?;
let mut lists = self.lists.write().unwrap();
*lists = ModerationLists { blacklisted, whitelisted };
info!(
"Moderation lists loaded: {} blacklisted, {} whitelisted",
lists.blacklisted.len(),
lists.whitelisted.len()
);
Ok(())
}
pub async fn is_allowed(&self, user_id: i64) -> bool {
let lists = self.lists.read().unwrap();
match self.share_mode {
ShareMode::B => !lists.blacklisted.contains(&user_id),
ShareMode::W => lists.whitelisted.contains(&user_id),
}
}
pub async fn blacklist(&self, user_id: i64) -> Result<()> {
{
let mut lists = self.lists.write().unwrap();
lists.blacklisted.insert(user_id);
}
self.save_blacklist().await?;
Ok(())
}
pub async fn whitelist(&self, user_id: i64) -> Result<()> {
{
let mut lists = self.lists.write().unwrap();
lists.whitelisted.insert(user_id);
}
self.save_whitelist().await?;
Ok(())
}
pub async fn remove_blacklist(&self, user_id: i64) -> Result<()> {
{
let mut lists = self.lists.write().unwrap();
lists.blacklisted.remove(&user_id);
}
self.save_blacklist().await?;
Ok(())
}
pub async fn remove_whitelist(&self, user_id: i64) -> Result<()> {
{
let mut lists = self.lists.write().unwrap();
lists.whitelisted.remove(&user_id);
}
self.save_whitelist().await?;
Ok(())
}
async fn save_blacklist(&self) -> Result<()> {
let ids = {
let lists = self.lists.read().unwrap();
lists.blacklisted.iter().copied().collect()
};
let data = IdListFile {
ids,
updated_at: chrono::Utc::now().to_rfc3339(),
};
let json = serde_json::to_string_pretty(&data)
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
tokio::fs::write(&self.blacklist_path, json)
.await
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
Ok(())
}
async fn save_whitelist(&self) -> Result<()> {
let ids = {
let lists = self.lists.read().unwrap();
lists.whitelisted.iter().copied().collect()
};
let data = IdListFile {
ids,
updated_at: chrono::Utc::now().to_rfc3339(),
};
let json = serde_json::to_string_pretty(&data)
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
tokio::fs::write(&self.whitelist_path, json)
.await
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
Ok(())
}
pub fn spawn_reload_task(self: Arc<Self>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
if let Err(e) = self.load().await {
warn!("Moderation list reload failed: {}", e);
}
}
});
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct IdListFile {
ids: Vec<i64>,
updated_at: String,
}
async fn load_id_set(path: &Path) -> Result<HashSet<i64>> {
if !path.exists() {
return Ok(HashSet::new());
}
let json = tokio::fs::read_to_string(path)
.await
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
let file: IdListFile = serde_json::from_str(&json)
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
Ok(file.ids.into_iter().collect())
}

View File

@@ -0,0 +1,38 @@
[package]
name = "cgcx-server"
version.workspace = true
edition.workspace = true
[[bin]]
name = "cgcx-server"
path = "src/main.rs"
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-config = { path = "../cgcx-config" }
cgcx-db = { path = "../cgcx-db" }
cgcx-storage = { path = "../cgcx-storage" }
cgcx-crypto = { path = "../cgcx-crypto" }
cgcx-content-typing = { path = "../cgcx-content-typing" }
cgcx-moderation = { path = "../cgcx-moderation" }
cgcx-file-pipeline = { path = "../cgcx-file-pipeline" }
axum = { version = "0.7", features = ["macros"] }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-gzip", "catch-panic", "timeout"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
tower = "0.5"
base64 = "0.21"
hex = "0.4"
tokio-stream = "0.1"
blake3 = "1.5"
sodiumoxide = "0.2"
tower_governor = "0.5"
argon2 = "0.5"
password-hash = "0.5"
hmac = "0.12"
sha2 = "0.10"
subtle = "2.5"

View File

@@ -0,0 +1 @@
pub fn placeholder() {}

View File

@@ -0,0 +1,713 @@
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use cgcx_config::Config;
use cgcx_core::{ContentId, CgcxError};
use cgcx_crypto::{unwrap_content_key, DecryptStream, MasterKey};
use cgcx_db::{Database, ContentRepo, ContentFileRepo};
use cgcx_storage::Storage;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tower_http::{
catch_panic::CatchPanicLayer,
compression::CompressionLayer,
cors::{AllowOrigin, CorsLayer},
services::{ServeDir, ServeFile},
timeout::TimeoutLayer,
trace::TraceLayer,
};
use tracing::{info, warn};
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal;
#[derive(Clone)]
struct AppState {
db: Arc<Database>,
storage: Arc<Storage>,
config: Arc<Config>,
master_key: Arc<MasterKey>,
cookie_secret: Vec<u8>,
allowed_roots: Arc<Vec<std::path::PathBuf>>,
}
#[derive(Serialize)]
struct HealthResponse {
status: String,
}
#[derive(Serialize)]
struct ContentMetadata {
cxid: String,
files: Vec<FileMetadata>,
has_password: bool,
max_views: Option<u64>,
current_views: u64,
allow_download: bool,
created_at: String,
}
#[derive(Serialize)]
struct FileMetadata {
idx: u32,
name: String,
mime: String,
size: u64,
render_flags: u32,
}
#[derive(Deserialize)]
struct VerifyPasswordRequest {
password: String,
}
#[derive(Deserialize)]
struct FileQuery {
#[serde(default)]
download: bool,
}
struct ByteRange {
start: u64,
end: Option<u64>,
}
struct AppError(CgcxError);
impl From<CgcxError> for AppError {
fn from(e: CgcxError) -> Self {
Self(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match self.0 {
CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
CgcxError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.as_str()),
CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"),
CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
};
(status, msg.to_string()).into_response()
}
}
type AppResult<T> = Result<T, AppError>;
#[tokio::main]
async fn main() -> cgcx_core::Result<()> {
tracing_subscriber::fmt::init();
let config = Arc::new(Config::load()?);
config.validate()?;
let db = Arc::new(Database::open("data/db.sqlite")?);
db.run_migrations().await?;
let storage = Arc::new(Storage::new(config.storage.paths.clone()));
storage.ensure_dirs().await?;
let master_key = match &config.crypto.aes_master_key_source {
cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var)?,
cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path)?,
};
master_key.log_startup(false);
let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec();
let allowed_roots = Arc::new(vec![
tokio::fs::canonicalize(&config.storage.paths.media).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.documents).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.text).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.temp).await.map_err(|e| CgcxError::Io(e))?,
]);
let state = AppState {
db,
storage,
config: config.clone(),
master_key: Arc::new(master_key),
cookie_secret,
allowed_roots,
};
let governor_conf = tower_governor::governor::GovernorConfigBuilder::default()
.period(Duration::from_secs(60) / config.rate_limiting.requests_per_minute)
.burst_size(config.rate_limiting.burst)
.finish()
.expect("invalid general rate limit config");
let password_governor_conf = tower_governor::governor::GovernorConfigBuilder::default()
.period(Duration::from_secs(60) / config.rate_limiting.password_attempts_per_minute)
.burst_size(3)
.finish()
.expect("invalid password rate limit config");
let password_route = Router::new()
.route("/api/content/{cxid}/verify-password", post(verify_password))
.layer(tower_governor::GovernorLayer {
config: Arc::new(password_governor_conf),
});
let static_service = ServeDir::new("frontend/dist")
.fallback(ServeFile::new("frontend/dist/index.html"));
let base_url = config.server.base_url.clone();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _request_parts: &_| {
if let Ok(origin_str) = origin.to_str() {
if origin_str == base_url {
return true;
}
// Allow localhost origins for development
if origin_str.starts_with("http://127.0.0.1:")
|| origin_str.starts_with("http://localhost:")
|| origin_str.starts_with("https://127.0.0.1:")
|| origin_str.starts_with("https://localhost:")
{
return true;
}
}
false
}))
.allow_methods([Method::GET, Method::POST, Method::HEAD, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT, header::ACCEPT_ENCODING, header::RANGE])
.allow_credentials(true)
.max_age(Duration::from_secs(86400));
let compression = CompressionLayer::new().compress_when(|_status: axum::http::StatusCode, _version: axum::http::Version, headers: &axum::http::HeaderMap, _extensions: &axum::http::Extensions| {
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| {
ct.starts_with("text/html")
|| ct.starts_with("text/css")
|| ct.starts_with("application/json")
|| ct.starts_with("text/plain")
})
.unwrap_or(false)
});
let app = Router::new()
.route("/api/health", get(health))
.route("/api/content/{cxid}", get(get_metadata))
.route("/api/content/{cxid}/file/{file_idx}", get(serve_file))
.merge(password_route)
.fallback_service(static_service)
.layer(tower_governor::GovernorLayer {
config: Arc::new(governor_conf),
})
.layer(compression)
.layer(cors)
.layer(axum::middleware::from_fn(security_headers))
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(30),
))
.layer(CatchPanicLayer::new())
.with_state(state.clone());
// Spawn background sweeper task
let db_clone = state.db.clone();
let storage_clone = state.storage.clone();
let config_clone = (*state.config).clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(24 * 60 * 60));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
info!("Running daily orphan cleanup");
let pipeline = cgcx_file_pipeline::FilePipeline::new(
(*storage_clone).clone(),
(*db_clone).clone(),
config_clone.clone(),
);
if let Err(e) = pipeline.cleanup_orphans().await {
warn!("Orphan cleanup failed: {}", e);
}
}
});
let addr = format!("{}:{}", config.server.bind_address, config.server.port);
info!("Server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| CgcxError::Io(e))?;
axum::serve(listener, app).await.map_err(|e| CgcxError::Io(e))?;
Ok(())
}
async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Response {
let mut response = next.run(req).await;
let headers = response.headers_mut();
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"),
);
headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"));
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"),
);
headers.insert(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
);
response
}
async fn health() -> impl IntoResponse {
axum::Json(HealthResponse {
status: "ok".into(),
})
}
async fn get_metadata(
State(state): State<AppState>,
Path(cxid): Path<String>,
headers: HeaderMap,
) -> AppResult<Response> {
let content_id = ContentId::try_from(cxid.as_str())?;
let repo = ContentRepo::new(state.db.conn());
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
return Err(CgcxError::NotFound.into());
}
if let Some(max) = content.max_views {
if content.view_count >= max {
return Ok(Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())
.unwrap());
}
}
if content.password_hash.is_some() {
let cookie_valid = headers
.get_all(header::COOKIE)
.iter()
.any(|v| {
v.to_str().ok().map(|s| {
s.split(';').any(|part| {
let part = part.trim();
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
})
}).unwrap_or(false)
});
if !cookie_valid {
return Err(CgcxError::Unauthorized.into());
}
}
let file_repo = ContentFileRepo::new(state.db.conn());
let files = file_repo.list_by_content(&content_id).await?;
let body = serde_json::to_vec(&ContentMetadata {
cxid: content.id.to_string(),
files: files.into_iter().map(|f| FileMetadata {
idx: f.file_index,
name: f.original_name,
mime: f.mime_type,
size: f.size_bytes,
render_flags: f.render_flags,
}).collect(),
has_password: content.password_hash.is_some(),
max_views: content.max_views,
current_views: content.view_count,
allow_download: content.allow_download,
created_at: content.created_at.to_rfc3339(),
}).map_err(|e| CgcxError::BadRequest(format!("json serialization: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap())
}
async fn verify_password(
State(state): State<AppState>,
Path(cxid): Path<String>,
Json(req): Json<VerifyPasswordRequest>,
) -> AppResult<impl IntoResponse> {
let content_id = ContentId::try_from(cxid.as_str())?;
let repo = ContentRepo::new(state.db.conn());
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
let Some(hash) = content.password_hash else {
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(&hash)
.map_err(|_| CgcxError::Crypto("invalid stored password hash".into()))?;
let valid = Argon2::default()
.verify_password(req.password.as_bytes(), &parsed_hash)
.is_ok();
if !valid {
return Err(CgcxError::Unauthorized.into());
}
let cookie_value = make_cookie_value(&cxid, &state.cookie_secret);
let cookie = format!(
"__Host-pw={}; Max-Age=3600; SameSite=Strict; Secure; HttpOnly; Path=/",
cookie_value
);
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::SET_COOKIE, cookie)
.body(Body::empty())
.unwrap())
}
async fn serve_file(
State(state): State<AppState>,
Path((cxid, file_idx)): Path<(String, u32)>,
Query(query): Query<FileQuery>,
headers: HeaderMap,
) -> AppResult<impl IntoResponse> {
let content_id = ContentId::try_from(cxid.as_str())?;
let repo = ContentRepo::new(state.db.conn());
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
return Err(CgcxError::NotFound.into());
}
if let Some(max) = content.max_views {
if content.view_count >= max {
return Ok(Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())
.unwrap());
}
}
if content.password_hash.is_some() {
let cookie_valid = headers
.get_all(header::COOKIE)
.iter()
.any(|v| {
v.to_str().ok().map(|s| {
s.split(';').any(|part| {
let part = part.trim();
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
})
}).unwrap_or(false)
});
if !cookie_valid {
return Err(CgcxError::Unauthorized.into());
}
}
if query.download && !content.allow_download {
return Err(CgcxError::Forbidden.into());
}
let file_repo = ContentFileRepo::new(state.db.conn());
let files = file_repo.list_by_content(&content_id).await?;
let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?;
// Path traversal validation
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
.map_err(|e| {
tracing::error!("canonicalize failed for {:?}: {}", file.stored_path, e);
CgcxError::Storage("invalid stored path".into())
})?;
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
tracing::error!("Path traversal blocked: {:?}", canonical_path);
return Err(CgcxError::Forbidden.into());
}
let etag = format!("\"{}\"", hex::encode(&file.encrypted_hash));
// If-None-Match check (skip increment)
if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) {
return Ok(Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag.clone())
.body(Body::empty())
.unwrap());
}
}
// Parse Range header
let range = if let Some(range_hdr) = headers.get(header::RANGE) {
if let Some(hdr_str) = range_hdr.to_str().ok() {
match parse_range(hdr_str, file.size_bytes) {
Some(r) => Some(r),
None => {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes))
.body(Body::empty())
.unwrap());
}
}
} else {
None
}
} else {
None
};
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?;
if let Some(max) = content.max_views {
if new_views >= max {
if !state.config.content.keep_content {
for f in &files {
if let Err(e) = tokio::fs::remove_file(&f.stored_path).await {
tracing::warn!("failed to remove file {:?}: {}", f.stored_path, e);
}
}
let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await;
}
repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?;
return Ok(Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())
.unwrap());
}
}
}
let content_type = file.mime_type.clone();
let sanitized_name = sanitize_content_disposition(&file.original_name);
let disposition = if query.download && content.allow_download {
format!("attachment; filename=\"{}\"", sanitized_name)
} else {
format!("inline; filename=\"{}\"", sanitized_name)
};
let (status, content_length, content_range) = if let Some(ref r) = range {
let end = r.end.unwrap_or(file.size_bytes - 1);
let len = end - r.start + 1;
let cr = format!("bytes {}-{}/{}", r.start, end, file.size_bytes);
(StatusCode::PARTIAL_CONTENT, len, Some(cr))
} else {
(StatusCode::OK, file.size_bytes, None)
};
let mut response = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ETAG, etag.clone())
.header(header::CONTENT_LENGTH, content_length.to_string());
if file.mime_type.starts_with("video/") || file.mime_type.starts_with("audio/") {
response = response.header(header::ACCEPT_RANGES, "bytes");
}
if let Some(cr) = content_range {
response = response.header(header::CONTENT_RANGE, cr);
}
if content.password_hash.is_some() {
response = response.header(header::CACHE_CONTROL, "private, no-store, max-age=0");
} else {
response = response.header(header::CACHE_CONTROL, "private, max-age=60");
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Vec<u8>, std::io::Error>>(4);
let path = file.stored_path.clone();
let master_key = state.master_key.clone();
let wrapped_key = file.encrypted_key_wrapped.clone();
let expected_hash = file.encrypted_hash.clone();
let file_size = file.size_bytes;
tokio::spawn(async move {
if let Err(e) = stream_decrypted_file(path, master_key, wrapped_key, tx, range, file_size, expected_hash).await {
warn!("stream error: {}", e);
}
});
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let body = Body::from_stream(body_stream);
Ok(response.body(body).unwrap())
}
async fn stream_decrypted_file(
path: std::path::PathBuf,
master_key: Arc<MasterKey>,
wrapped_key: Vec<u8>,
tx: tokio::sync::mpsc::Sender<Result<Vec<u8>, std::io::Error>>,
_range: Option<ByteRange>,
_file_size: u64,
expected_hash: Vec<u8>,
) -> cgcx_core::Result<()> {
let mut file = tokio::fs::File::open(&path).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
let mut header_buf = [0u8; 24];
file.read_exact(&mut header_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
let content_key = unwrap_content_key(&wrapped_key, &master_key)?;
let header = sodiumoxide::crypto::secretstream::xchacha20poly1305::Header::from_slice(&header_buf)
.ok_or_else(|| CgcxError::Crypto("invalid header".into()))?;
let mut decrypt_stream = DecryptStream::new(&content_key, &header)?;
let mut len_buf = [0u8; 4];
let mut saw_final = false;
loop {
if file.read_exact(&mut len_buf).await.is_err() {
break; // EOF at message boundary
}
let msg_len = u32::from_le_bytes(len_buf) as usize;
let mut msg_buf = vec![0u8; msg_len];
file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
match decrypt_stream.pull(&msg_buf) {
Ok((plaintext, tag)) => {
if tx.send(Ok(plaintext)).await.is_err() {
return Ok(()); // client disconnected
}
if tag == TagFinal {
saw_final = true;
break;
}
}
Err(e) => {
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await;
return Err(e);
}
}
}
if !saw_final {
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "stream ended without Final tag"))).await;
return Err(CgcxError::Crypto("stream ended without Final tag".into()));
}
let computed_hash = decrypt_stream.finalize().to_vec();
if computed_hash != expected_hash {
tracing::error!(target: "critical", "BLAKE3 integrity mismatch for file {:?}: expected {} got {}", path, hex::encode(&expected_hash), hex::encode(&computed_hash));
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "integrity check failed"))).await;
return Err(CgcxError::Crypto("BLAKE3 integrity mismatch".into()));
}
Ok(())
}
fn parse_range(range_hdr: &str, file_size: u64) -> Option<ByteRange> {
const PREFIX: &str = "bytes=";
if !range_hdr.starts_with(PREFIX) {
return None;
}
let rest = &range_hdr[PREFIX.len()..];
// Basic version: only single-byte range
if rest.contains(',') {
return None;
}
let mut parts = rest.splitn(2, '-');
let start_str = parts.next()?.trim();
let end_str = parts.next()?.trim();
if start_str.is_empty() && end_str.is_empty() {
return None;
}
if start_str.is_empty() {
let suffix_len: u64 = end_str.parse().ok()?;
let start = file_size.saturating_sub(suffix_len);
Some(ByteRange {
start,
end: Some(file_size.saturating_sub(1)),
})
} else if end_str.is_empty() {
let start: u64 = start_str.parse().ok()?;
if start >= file_size {
return None;
}
Some(ByteRange { start, end: None })
} else {
let start: u64 = start_str.parse().ok()?;
let end: u64 = end_str.parse().ok()?;
if start > end || start >= file_size {
return None;
}
let end = end.min(file_size - 1);
Some(ByteRange { start, end: Some(end) })
}
}
fn sanitize_content_disposition(name: &str) -> String {
name.chars()
.filter(|c| !c.is_control())
.map(|c| match c {
'\\' => "\\\\".to_string(),
'"' => "\\\"".to_string(),
c => c.to_string(),
})
.collect()
}
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
fn hmac_cookie(cxid: &str, secret: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
mac.update(cxid.as_bytes());
mac.finalize().into_bytes().to_vec()
}
fn make_cookie_value(cxid: &str, secret: &[u8]) -> String {
use base64::Engine;
let mac = hmac_cookie(cxid, secret);
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)
}
fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool {
use base64::Engine;
let decoded = match base64::engine::general_purpose::STANDARD.decode(cookie_value) {
Ok(d) => d,
Err(_) => return false,
};
let mut parts = decoded.splitn(2, |&b| b == b':');
let decoded_cxid = match parts.next() {
Some(p) => match std::str::from_utf8(p) {
Ok(s) => s,
Err(_) => return false,
},
None => return false,
};
let mac_bytes = match parts.next() {
Some(p) => p,
None => return false,
};
if decoded_cxid != cxid {
return false;
}
let expected = hmac_cookie(cxid, secret);
if mac_bytes.len() != expected.len() {
return false;
}
use subtle::ConstantTimeEq;
mac_bytes.ct_eq(&expected).into()
}

View File

@@ -0,0 +1,11 @@
[package]
name = "cgcx-storage"
version.workspace = true
edition.workspace = true
[dependencies]
cgcx-core = { path = "../cgcx-core" }
cgcx-config = { path = "../cgcx-config" }
tokio = { version = "1", features = ["fs", "io-util"] }
tracing = "0.1"
tempfile = "3"

View File

@@ -0,0 +1,86 @@
use cgcx_config::StoragePaths;
use cgcx_core::{ContentId, Result, CgcxError};
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Clone)]
pub struct Storage {
paths: StoragePaths,
}
impl Storage {
pub fn new(paths: StoragePaths) -> Self {
Self { paths }
}
pub async fn ensure_dirs(&self) -> Result<()> {
for dir in [&self.paths.media, &self.paths.documents, &self.paths.text, &self.paths.temp] {
fs::create_dir_all(dir).await.map_err(|e| CgcxError::Storage(format!("create dir {:?}: {}", dir, e)))?;
}
Ok(())
}
pub fn media_dir(&self) -> &Path {
&self.paths.media
}
pub fn documents_dir(&self) -> &Path {
&self.paths.documents
}
pub fn text_dir(&self) -> &Path {
&self.paths.text
}
pub fn temp_dir(&self) -> &Path {
&self.paths.temp
}
pub fn content_dir(&self, content_id: &ContentId, mime_type: &str) -> PathBuf {
let base = if mime_type.starts_with("image/") || mime_type.starts_with("video/") || mime_type.starts_with("audio/") {
&self.paths.media
} else if mime_type.starts_with("text/") {
&self.paths.text
} else {
&self.paths.documents
};
base.join(content_id.as_str())
}
pub fn file_path(&self, content_id: &ContentId, file_index: u32, mime_type: &str) -> Result<PathBuf> {
let base = if mime_type.starts_with("image/") || mime_type.starts_with("video/") || mime_type.starts_with("audio/") {
&self.paths.media
} else if mime_type.starts_with("text/") {
&self.paths.text
} else {
&self.paths.documents
};
let dir = base.join(content_id.as_str());
let file_name = format!("{}_{:04}.enc", content_id.as_str(), file_index);
let path = dir.join(file_name);
if !path.starts_with(base) {
return Err(CgcxError::Storage("path traversal detected".into()));
}
Ok(path)
}
pub fn temp_file(&self) -> Result<tempfile::NamedTempFile> {
tempfile::NamedTempFile::new_in(&self.paths.temp)
.map_err(|e| CgcxError::Storage(format!("create temp file: {}", e)))
}
pub async fn delete_content_files(&self, content_id: &ContentId, mime_type: &str) -> Result<()> {
let dir = self.content_dir(content_id, mime_type);
if dir.exists() {
fs::remove_dir_all(&dir).await.map_err(|e| CgcxError::Storage(format!("remove dir {:?}: {}", dir, e)))?;
}
Ok(())
}
pub async fn file_size(&self, path: &Path) -> Result<u64> {
let meta = fs::metadata(path).await.map_err(|e| CgcxError::Storage(format!("metadata {:?}: {}", path, e)))?;
Ok(meta.len())
}
}

129
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,129 @@
# Architecture & Design Decisions
This document explains the deeper design choices behind cg.cx — the trade-offs, threat models, and engineering rationale that shaped the system.
---
## Why XChaCha20-Poly1305 over AES-GCM?
We chose **XChaCha20-Poly1305** (via libsodium's `crypto_secretstream_xchacha20poly1305`) as the bulk encryption primitive for several reasons:
1. **Nonce-misuse resistance**: AES-GCM's security collapses catastrophically if a nonce is ever reused. XChaCha20 uses a 192-bit nonce, making accidental collisions statistically impossible even with billions of files. This removes an entire class of operator error.
2. **No hardware dependency**: AES-GCM performance relies heavily on AES-NI. XChaCha20 performs well on all platforms — including older or virtualized CPUs where AES-NI may be unavailable or disabled.
3. **Streaming integrity**: libsodium's `secretstream` API provides built-in chunked authenticated encryption with `Message` and `Final` tags. This gives us streaming decryption with per-chunk integrity checks without inventing our own framing protocol.
4. **Simpler key management**: Because nonce collisions are not a practical concern, we can generate a fresh random key for every file without tracking nonce counters or key lifecycles.
AES is still present in the system — we use **AES-256-KW** (Key Wrap) to encrypt the per-file content keys (CEKs) with the master key. AES-KW was chosen because it is a standard, deterministic, and widely audited key-wrapping algorithm with built-in integrity.
---
## Why SQLite over PostgreSQL?
For a self-hosted, single-tenant service handling encrypted file metadata, **SQLite** is the correct default:
1. **Operational simplicity**: No separate database server to install, upgrade, or network-secure. A single `.sqlite` file is trivial to back up, replicate, or inspect.
2. **WAL mode performance**: With `PRAGMA journal_mode = WAL`, SQLite handles concurrent readers and a single writer efficiently — enough for a bot + web server pair.
3. **Schema simplicity**: The schema is small (5 tables, 2 migration files). The overhead of a client/server RDBMS is unjustified.
4. **Deployment footprint**: Ideal for running on a small VPS or even an embedded edge device without container orchestration.
If future requirements demand horizontal scaling or heavy analytics, the repository pattern in `cgcx-db` makes it straightforward to swap in PostgreSQL without touching the bot or server code.
---
## Why a Modular 10-Crate Workspace?
The crate graph was designed to enforce architectural boundaries at compile time:
```
cgcx-core
├── cgcx-config
├── cgcx-crypto
├── cgcx-db
├── cgcx-storage
├── cgcx-content-typing
│ ▲
│ └── cgcx-file-pipeline
├── cgcx-moderation
└── binaries: cgcx-bot, cgcx-server
```
- **cgcx-core** sits at the root and contains only pure data types. It has no I/O dependencies, making it safe to import anywhere.
- **cgcx-crypto** depends only on `cgcx-core`. It is side-effect-free and easy to property-test.
- **cgcx-db** and **cgcx-storage** are I/O crates but know nothing about Telegram or HTTP.
- **cgcx-file-pipeline** composes crypto, storage, typing, and DB into the upload workflow.
- The **binaries** are thin shells that wire configuration to the library crates.
This structure makes it impossible for a database query to accidentally invoke Telegram API code, or for HTTP handlers to directly touch the filesystem without going through the storage abstraction.
---
## Streaming Design for Large Files
Uploads from Telegram are bounded by Telegram's own file size limits (currently 2 GB for bots), but we still treat streaming as a first-class concern:
### Upload Path
1. The bot downloads the file into a `Vec<u8>` in memory.
2. The file pipeline encrypts the data in 1 MiB chunks, writing ciphertext directly to a temp file on disk.
3. After the final chunk is written and flushed, the temp file is atomically renamed to its final destination.
4. Only metadata (original name, MIME type, wrapped key, BLAKE3 hash) hits the database.
This ensures that even a 1 GB upload does not require a 1 GB contiguous memory allocation for ciphertext.
### Download Path
1. The Axum handler spawns a Tokio task that opens the encrypted file.
2. It reads the 24-byte secretstream header, unwraps the CEK, and initializes a `DecryptStream`.
3. A bounded MPSC channel (`capacity = 4`) decouples disk I/O from the HTTP response stream.
4. Ciphertext is read from disk in ~1 MiB chunks, decrypted, and sent through the channel.
5. Axum's `Body::from_stream` forwards plaintext chunks to the client as they are produced.
If the client disconnects mid-stream, the sender half of the channel is dropped and the decryption task exits cleanly. No full-file buffering occurs on the server.
---
## Security Threat Model
### What We Protect Against
| Threat | Mitigation |
|--------|------------|
| **Server compromise (passive)** | All files are encrypted at rest with per-file keys. An attacker with disk access cannot read plaintext without the master key. |
| **Database leak** | The database contains only wrapped keys, ciphertext hashes, and metadata. It does not contain plaintext or unwrapped CEKs. |
| **Ciphertext tampering** | XChaCha20-Poly1305 authenticates every chunk. Tampered files fail decryption and the stream aborts. |
| **Brute-force password guessing** | Per-content passwords are hashed with bcrypt. Rate limiting on `/api/content/:cxid/verify-password` slows online attacks. |
| **Cookie forgery** | Password session cookies include a BLAKE3 MAC keyed by the master key. Forging a cookie requires knowledge of the master key. |
| **Replay / enumeration** | Content IDs are 12-character random strings with ~71 bits of entropy. They are not sequential. |
| **Malicious uploads** | Content typing flags executable, HTML, and script MIME types. The frontend refuses to inline dangerous files. |
### What We Do Not Protect Against
| Threat | Rationale |
|--------|-----------|
| **Active server compromise (key extraction)** | If an attacker gains code execution and reads the master key from memory or env, they can decrypt all content. This is an inherent limitation of server-side encryption. |
| **Telegram MitM** | We trust Telegram's bot API transport (HTTPS) and file CDN. |
| **Client-side malware** | The user's browser or device may be compromised; we cannot protect plaintext after decryption. |
| **Denial of Service** | Large uploads and high request volumes can exhaust disk or bandwidth. Rate limiting and upload size caps mitigate but do not eliminate this risk. |
### Trust Boundaries
```
[User Device] --HTTPS--> [Telegram Cloud] --HTTPS--> [cg.cx Bot]
|
[Browser] <--HTTPS--> [cg.cx Server] <--------┘
|
Decrypted plaintext rendered in browser
```
The **cg.cx server** is a trusted party for decryption and delivery. It is not a true "end-to-end" system in the Signal sense, because the server must unwrap keys to stream content to browsers that do not possess the master key. The architecture prioritizes **usable sharing** (anyone with a link can view) over **true E2EE** (which would require client-side JavaScript crypto and key distribution).
---
## Future Considerations
- **Client-side decryption**: A future iteration could deliver the wrapped CEK to the browser and decrypt via WebAssembly / libsodium-js. This would remove the server from the trust boundary for delivery.
- **S3-compatible backends**: `cgcx-storage` could be abstracted into a trait to support object storage.
- **PostgreSQL backend**: The repository trait pattern in `cgcx-db` is amenable to an async SQLx implementation.
- **Metrics and alerting**: Structured tracing is in place; a metrics exporter (Prometheus) could be added to `cgcx-server` without touching business logic.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<title>cg.cx</title>
</head>
<body>
<div id="loading-screen"></div>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "cgcx-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
},
"dependencies": {
"marked": "^12.0.0",
"dompurify": "^3.0.0"
}
}

Binary file not shown.

Binary file not shown.

29
frontend/src/App.svelte Normal file
View File

@@ -0,0 +1,29 @@
<script>
import Home from './routes/Home.svelte'
import ViewContent from './routes/ViewContent.svelte'
import LoadingScreen from './components/LoadingScreen.svelte'
let cxid = $state('')
let sc = $state('')
function readParams() {
const params = new URLSearchParams(window.location.search)
cxid = params.get('cxid') || ''
sc = params.get('sc') || ''
}
$effect(() => {
readParams()
const onPopState = () => readParams()
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
})
</script>
<LoadingScreen />
{#if cxid}
<ViewContent {cxid} {sc} />
{:else}
<Home />
{/if}

View File

@@ -0,0 +1,31 @@
<script>
let { src, mime } = $props()
</script>
<div class="audio-player">
<div class="label">[ Audio ]</div>
<audio controls preload="metadata" {src}></audio>
</div>
<style>
.audio-player {
max-width: 600px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.label {
font-family: 'Press Start 2P', cursive;
font-size: 0.7rem;
color: var(--retro-green);
}
audio {
width: 100%;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import { formatSize } from '../lib/api.js'
let { file, downloadUrl } = $props()
</script>
<div class="document-card">
<div class="icon">[DOC]</div>
<div class="info">
<p class="name">{file.name}</p>
<p class="meta">{file.mime}{formatSize(file.size)}</p>
</div>
<a class="btn" href={downloadUrl} download={file.name}>Download</a>
</div>
<style>
.document-card {
max-width: 600px;
margin: 24px auto;
padding: 20px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.icon {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-green);
}
.info { flex: 1; min-width: 0; }
.name {
font-family: 'Press Start 2P', cursive;
font-size: 0.65rem;
word-break: break-all;
}
.meta {
font-size: 0.9rem;
color: #666;
margin-top: 4px;
}
.btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
padding: 10px 14px;
border: 3px solid var(--retro-green);
background: var(--retro-panel);
color: var(--retro-green);
text-decoration: none;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
}
.btn:hover {
background: var(--retro-green);
color: #fff;
}
</style>

View File

@@ -0,0 +1,56 @@
<script>
import { formatSize } from '../lib/api.js'
let { file, downloadUrl } = $props()
</script>
<div class="warning-card">
<div class="badge">[!] DANGEROUS FILE</div>
<p class="name">{file.name}</p>
<p class="meta">{file.mime}{formatSize(file.size)}</p>
<p class="notice">
This file may be dangerous. Never execute unknown files. Only download if you trust the source.
</p>
<a class="btn danger" href={downloadUrl} download={file.name}>Download at your own risk</a>
</div>
<style>
.warning-card {
max-width: 600px;
margin: 24px auto;
padding: 24px;
background: #fff8f8;
border: 3px solid var(--retro-danger);
box-shadow: 6px 6px 0px rgba(139,26,26,0.15);
text-align: center;
}
.badge {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-danger);
margin-bottom: 12px;
}
.name {
font-family: 'Press Start 2P', cursive;
font-size: 0.7rem;
word-break: break-all;
}
.meta {
font-size: 0.9rem;
color: #666;
margin: 8px 0;
}
.notice {
font-size: 1rem;
color: #555;
margin: 16px 0;
}
.btn.danger {
border-color: var(--retro-danger);
color: var(--retro-danger);
}
.btn.danger:hover {
background: var(--retro-danger);
color: #fff;
}
</style>

View File

@@ -0,0 +1,22 @@
<script>
let { src, name } = $props()
</script>
<div class="image-viewer">
<img {src} alt={name} decoding="async" loading="eager" />
</div>
<style>
.image-viewer {
display: flex;
justify-content: center;
padding: 16px;
}
img {
max-width: 100%;
max-height: 85vh;
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
image-rendering: auto;
}
</style>

View File

@@ -0,0 +1,160 @@
<script>
let visible = $state(true)
let progress = $state(0)
let fading = $state(false)
let on = $state(false)
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
$effect(() => {
if (reducedMotion) {
on = true
progress = 100
setTimeout(() => {
fading = true
setTimeout(() => { visible = false }, 400)
}, 300)
return
}
requestAnimationFrame(() => { on = true })
const duration = 1600
const start = performance.now()
function tick(now) {
const elapsed = now - start
progress = Math.min(100, (elapsed / duration) * 100)
if (progress < 100) {
requestAnimationFrame(tick)
} else {
setTimeout(() => {
fading = true
setTimeout(() => { visible = false }, 700)
}, 300)
}
}
requestAnimationFrame(tick)
})
</script>
{#if visible}
<div class="loading-screen" class:fading class:on>
<div class="scanlines"></div>
<div class="curvature"></div>
<div class="content">
<div class="logo">CG.CX</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
</div>
</div>
{/if}
<style>
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--retro-bg);
display: flex;
align-items: center;
justify-content: center;
transform: scaleY(0.005) scaleX(0.8);
opacity: 0;
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.3s ease;
overflow: hidden;
}
.loading-screen.on {
transform: scaleY(1) scaleX(1);
opacity: 1;
}
.loading-screen.fading {
opacity: 0;
pointer-events: none;
transition: opacity 0.7s ease;
}
.scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.04) 2px,
rgba(0,0,0,0.04) 4px
);
animation: scanline-flicker 0.1s infinite;
}
@keyframes scanline-flicker {
0%, 100% { opacity: 0.95; }
50% { opacity: 1; }
}
.curvature {
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 80px rgba(0,0,0,0.12);
border-radius: 15% / 5%;
}
.content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.logo {
font-family: 'Press Start 2P', cursive;
font-size: clamp(1.4rem, 5vw, 2.4rem);
color: var(--retro-green);
text-shadow: 2px 2px 0px rgba(0,0,0,0.1);
animation: glitch-reveal 0.6s ease-out 0.3s both;
}
@keyframes glitch-reveal {
0% { clip-path: inset(0 100% 0 0); transform: translateX(-6px); }
25% { clip-path: inset(0 70% 0 0); transform: translateX(5px); }
50% { clip-path: inset(0 30% 0 0); transform: translateX(-3px); }
75% { clip-path: inset(0 10% 0 0); transform: translateX(2px); }
100% { clip-path: inset(0 0 0 0); transform: translateX(0); }
}
.progress-bar {
width: min(300px, 80vw);
height: 10px;
background: #ddd;
border: 2px solid var(--retro-border);
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--retro-green), var(--retro-green-light));
transition: width 0.05s linear;
}
@media (prefers-reduced-motion: reduce) {
.loading-screen {
transform: none;
opacity: 1;
transition: opacity 0.3s ease;
}
.scanlines {
display: none;
}
.logo {
animation: none;
}
.progress-fill {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script>
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { src } = $props()
let html = $state('')
let loading = $state(true)
$effect(() => {
fetch(src)
.then(r => r.text())
.then(text => {
try {
const raw = marked.parse(text, { async: false })
html = DOMPurify.sanitize(raw)
} catch (e) {
html = '<p class="error">Failed to render markdown.</p>'
}
loading = false
})
.catch(() => {
html = '<p class="error">Failed to load markdown.</p>'
loading = false
})
})
</script>
<div class="markdown-renderer">
{#if loading}
<p>Loading markdown...</p>
{:else}
{@html html}
{/if}
</div>
<style>
.markdown-renderer {
max-width: 800px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
font-size: 1.05rem;
line-height: 1.6;
}
.markdown-renderer :global(h1), .markdown-renderer :global(h2), .markdown-renderer :global(h3) {
font-family: 'Press Start 2P', cursive;
color: var(--retro-green);
margin: 1.2em 0 0.6em;
font-size: 0.9rem;
}
.markdown-renderer :global(pre) {
background: #1a1a1a;
color: #e0e0e0;
padding: 12px;
overflow-x: auto;
border: 2px solid var(--retro-border);
font-family: 'VT323', monospace;
font-size: 1.1rem;
}
.markdown-renderer :global(code) {
font-family: 'VT323', monospace;
background: #eee;
padding: 2px 6px;
}
.markdown-renderer :global(pre code) {
background: transparent;
padding: 0;
}
.markdown-renderer :global(p) { margin: 0.8em 0; }
.markdown-renderer :global(ul), .markdown-renderer :global(ol) { margin: 0.8em 0 0.8em 1.5em; }
.markdown-renderer :global(blockquote) {
border-left: 4px solid var(--retro-green);
padding-left: 12px;
color: #444;
margin: 0.8em 0;
}
</style>

View File

@@ -0,0 +1,82 @@
<script>
import { fileUrl } from '../lib/api.js'
import ImageViewer from './ImageViewer.svelte'
import VideoPlayer from './VideoPlayer.svelte'
import AudioPlayer from './AudioPlayer.svelte'
import MarkdownRenderer from './MarkdownRenderer.svelte'
import TextViewer from './TextViewer.svelte'
import DocumentCard from './DocumentCard.svelte'
import ExecutableWarning from './ExecutableWarning.svelte'
let { files, cxid, password = '' } = $props()
function getViewer(file) {
const flags = file.render_flags || 0
if (flags & 1) return 'image'
if (flags & 2) return 'video'
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 64 || flags & 128) return 'dangerous'
return 'document'
}
</script>
<div class="gallery">
{#each files as file, i (file.idx)}
{@const viewer = getViewer(file)}
<div class="item">
<div class="item-header">
<span class="item-index">#{i + 1}</span>
<span class="item-name">{file.name}</span>
</div>
{#if viewer === 'image'}
<ImageViewer src={fileUrl(cxid, file.idx, false, password)} name={file.name} />
{:else if viewer === 'video'}
<VideoPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'audio'}
<AudioPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}
</div>
{/each}
</div>
<style>
.gallery {
max-width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.item {
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
.item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #f0f0f0;
border-bottom: 2px solid var(--retro-border);
}
.item-index {
font-family: 'Press Start 2P', cursive;
font-size: 0.5rem;
color: var(--retro-green);
}
.item-name {
font-size: 1rem;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,46 @@
<script>
let { src } = $props()
let text = $state('')
let loading = $state(true)
$effect(() => {
fetch(src)
.then(r => r.text())
.then(t => {
text = t
loading = false
})
.catch(() => {
text = 'Failed to load text content.'
loading = false
})
})
</script>
<div class="text-viewer">
{#if loading}
<p>Loading text...</p>
{:else}
<pre>{text}</pre>
{/if}
</div>
<style>
.text-viewer {
max-width: 900px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'VT323', monospace;
font-size: 1.1rem;
line-height: 1.5;
max-height: 80vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,23 @@
<script>
let { src, mime } = $props()
</script>
<div class="video-player">
<!-- svelte-ignore a11y_media_has_caption -->
<video controls preload="metadata" {src}></video>
</div>
<style>
.video-player {
display: flex;
justify-content: center;
padding: 16px;
}
video {
max-width: 100%;
max-height: 80vh;
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
background: #000;
}
</style>

38
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE = "http://127.0.0.1:8090";
export async function fetchMetadata(cxid) {
const res = await fetch(
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function verifyPassword(cxid, password) {
const res = await fetch(
`${API_BASE}/api/content/${encodeURIComponent(cxid)}/verify-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
credentials: "same-origin",
},
);
return res.ok;
}
export function fileUrl(cxid, fileIdx, download = false, password = "") {
let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}/file/${fileIdx}`;
if (download) url += "?download=1";
if (password)
url += (download ? "&" : "?") + `sc=${encodeURIComponent(password)}`;
return url;
}
export function formatSize(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}

12
frontend/src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './styles/global.css'
const loadingScreen = document.getElementById('loading-screen')
if (loadingScreen) loadingScreen.remove()
const app = mount(App, {
target: document.getElementById('app'),
})
export default app

View File

@@ -0,0 +1,128 @@
<script>
import { fetchMetadata, verifyPassword } from '../lib/api.js'
let cxidInput = $state('')
let passwordInput = $state('')
let needsPassword = $state(false)
let loading = $state(false)
let error = $state('')
async function submit() {
error = ''
if (!cxidInput.trim()) return
loading = true
try {
const meta = await fetchMetadata(cxidInput.trim())
if (meta.has_password && !passwordInput) {
needsPassword = true
loading = false
return
}
if (meta.has_password) {
const ok = await verifyPassword(cxidInput.trim(), passwordInput)
if (!ok) {
error = 'Incorrect password.'
loading = false
return
}
}
const url = new URL(window.location.href)
url.searchParams.set('cxid', cxidInput.trim())
if (passwordInput) url.searchParams.set('sc', passwordInput)
history.pushState({}, '', url.toString())
window.dispatchEvent(new PopStateEvent('popstate'))
} catch (e) {
error = e.message || 'Content not found.'
loading = false
}
}
function onKeydown(e) {
if (e.key === 'Enter') submit()
}
</script>
<main class="home">
<div class="hero">
<h1 class="retro-heading">CG.CX</h1>
<p class="tagline">Secure content sharing</p>
</div>
<div class="panel">
<label for="cxid">Content ID</label>
<input id="cxid" type="text" bind:value={cxidInput} placeholder="Enter content ID..." onkeydown={onKeydown} />
{#if needsPassword}
<label for="pw">Password</label>
<input id="pw" type="password" bind:value={passwordInput} placeholder="Enter password..." onkeydown={onKeydown} />
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
<button onclick={submit} disabled={loading}>
{loading ? 'Loading...' : '[ Unlock ]'}
</button>
</div>
<footer>
<p>Developed by <a href="https://t.me/forgecadrape" target="_blank" rel="noopener">@forgecadrape</a></p>
<p>portfolio <a href="https://kittens.rip/" target="_blank" rel="noopener">kittens.rip</a></p>
<p class="footer-small">Created with &lt;3 in Europe<br/>@2026 <a href="https://cg.cx/">cg.cx</a></p>
</footer>
</main>
<style>
.home {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 32px;
}
.hero {
text-align: center;
}
.tagline {
font-size: 1.2rem;
color: var(--retro-green-light);
margin-top: 8px;
letter-spacing: 2px;
}
.panel {
width: min(400px, 100%);
background: var(--retro-panel);
border: 3px solid var(--retro-border);
padding: 24px;
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.panel label {
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
color: var(--retro-green);
text-transform: uppercase;
}
.error {
color: var(--retro-danger);
font-size: 1rem;
}
footer {
text-align: center;
font-size: 0.95rem;
color: #555;
}
footer p {
margin: 4px 0;
}
.footer-small {
font-size: 0.85rem;
margin-top: 12px;
color: #777;
}
</style>

View File

@@ -0,0 +1,183 @@
<script>
import { fetchMetadata, verifyPassword, fileUrl } from '../lib/api.js'
import ImageViewer from '../components/ImageViewer.svelte'
import VideoPlayer from '../components/VideoPlayer.svelte'
import AudioPlayer from '../components/AudioPlayer.svelte'
import MarkdownRenderer from '../components/MarkdownRenderer.svelte'
import TextViewer from '../components/TextViewer.svelte'
import DocumentCard from '../components/DocumentCard.svelte'
import ExecutableWarning from '../components/ExecutableWarning.svelte'
import MixedGallery from '../components/MixedGallery.svelte'
let { cxid, sc } = $props()
let phase = $state('loading_meta') // loading_meta | password_required | loading_content | rendering | error
let metadata = $state(null)
let password = $state('')
let error = $state('')
$effect(() => {
password = sc || ''
})
$effect(() => {
load()
})
async function load() {
phase = 'loading_meta'
error = ''
try {
const meta = await fetchMetadata(cxid)
metadata = meta
if (meta.has_password && !password) {
phase = 'password_required'
return
}
if (meta.has_password) {
const ok = await verifyPassword(cxid, password)
if (!ok) {
phase = 'password_required'
error = 'Incorrect password.'
return
}
}
phase = 'rendering'
} catch (e) {
phase = 'error'
error = e.message || 'Failed to load content.'
}
}
async function submitPassword() {
error = ''
if (!password) return
const ok = await verifyPassword(cxid, password)
if (ok) {
phase = 'rendering'
} else {
error = 'Incorrect password.'
}
}
function goHome() {
history.pushState({}, '', '/')
window.dispatchEvent(new PopStateEvent('popstate'))
}
function getViewerFor(file) {
const flags = file.render_flags || 0
if (flags & 1) return 'image'
if (flags & 2) return 'video'
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 64) return 'executable'
if (flags & 128) return 'dangerous'
return 'document'
}
</script>
<main class="view">
{#if phase === 'loading_meta'}
<div class="center">
<p class="retro-heading">Loading...</p>
</div>
{:else if phase === 'password_required'}
<div class="center">
<div class="panel">
<p class="retro-heading">[ Protected ]</p>
<p>This content requires a password.</p>
<input type="password" bind:value={password} placeholder="Password..." onkeydown={(e) => e.key === 'Enter' && submitPassword()} />
{#if error}<p class="error">{error}</p>{/if}
<button onclick={submitPassword}>Unlock</button>
<button class="secondary" onclick={goHome}>Back</button>
</div>
</div>
{:else if phase === 'error'}
<div class="center">
<p class="error">{error}</p>
<button onclick={goHome}>Home</button>
</div>
{:else if phase === 'rendering'}
<div class="content-header">
<button class="small" onclick={goHome}>&lt;- Home</button>
<span class="meta">{metadata.files.length} file{metadata.files.length !== 1 ? 's' : ''}</span>
{#if metadata.max_views}
<span class="meta">Views: {metadata.current_views}/{metadata.max_views}</span>
{/if}
</div>
{#if metadata.files.length === 1}
{@const file = metadata.files[0]}
{@const viewer = getViewerFor(file)}
{#if viewer === 'image'}
<ImageViewer src={fileUrl(cxid, file.idx, false, password)} name={file.name} />
{:else if viewer === 'video'}
<VideoPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'audio'}
<AudioPlayer src={fileUrl(cxid, file.idx, false, password)} mime={file.mime} />
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'executable' || viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}
{:else}
<MixedGallery files={metadata.files} {cxid} {password} />
{/if}
{/if}
</main>
<style>
.view {
min-height: 100vh;
padding: 16px;
}
.center {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.panel {
width: min(400px, 100%);
background: var(--retro-panel);
border: 3px solid var(--retro-border);
padding: 24px;
box-shadow: 6px 6px 0px var(--retro-shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.error { color: var(--retro-danger); }
.content-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--retro-border);
}
.meta {
font-size: 0.9rem;
color: #555;
background: #eee;
padding: 4px 8px;
border: 1px solid var(--retro-border);
}
button.small {
font-size: 0.5rem;
padding: 8px 10px;
}
button.secondary {
border-color: #999;
color: #555;
}
</style>

View File

@@ -0,0 +1,124 @@
@import './retro-theme.css';
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/press-start-2p-latin.woff2') format('woff2');
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/vt323-latin.woff2') format('woff2');
}
html {
font-size: 18px;
}
body {
font-family: 'VT323', monospace;
background: var(--retro-bg);
color: var(--retro-fg);
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--retro-green);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button, .btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.65rem;
padding: 12px 16px;
border: 3px solid var(--retro-green);
background: var(--retro-panel);
color: var(--retro-green);
cursor: pointer;
text-transform: uppercase;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
}
button:disabled, .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="text"],
input[type="password"] {
font-family: 'VT323', monospace;
font-size: 1.1rem;
padding: 10px 12px;
border: 2px solid var(--retro-border);
background: #fff;
color: var(--retro-fg);
outline: none;
width: 100%;
box-sizing: border-box;
}
input:focus {
border-color: var(--retro-green);
}
.retro-heading {
font-family: 'Press Start 2P', cursive;
font-size: clamp(0.9rem, 3vw, 1.4rem);
line-height: 1.6;
color: var(--retro-green);
background: linear-gradient(135deg, var(--retro-green), var(--retro-green-light), var(--retro-green));
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@media (prefers-reduced-motion: no-preference) {
button, .btn {
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
button:hover, .btn:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0px rgba(0,0,0,0.2);
}
button:active, .btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0px rgba(0,0,0,0.15);
}
.retro-heading {
animation: gradient-shift 3s ease infinite;
}
}
@media (max-width: 768px) {
html { font-size: 17px; }
}
@media (max-width: 480px) {
html { font-size: 16px; }
button, .btn { font-size: 0.55rem; padding: 10px 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,12 @@
:root {
--retro-bg: #fafafa;
--retro-fg: #111111;
--retro-green: #1a4a1a;
--retro-green-light: #2e8b2e;
--retro-accent: #0f380f;
--retro-border: #cccccc;
--retro-panel: #ffffff;
--retro-shadow: rgba(0, 0, 0, 0.12);
--retro-danger: #8b1a1a;
--retro-warning: #8b5a1a;
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

58
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,58 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY,
telegram_username TEXT,
first_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'admin', 'banned')),
accepted_terms_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE contents (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('staged', 'active', 'deleted', 'blacklisted')),
view_count INTEGER NOT NULL DEFAULT 0,
max_views INTEGER,
allow_download INTEGER NOT NULL DEFAULT 1,
password_hash TEXT,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
deleted_at DATETIME
);
CREATE TABLE content_files (
content_id TEXT NOT NULL REFERENCES contents(id),
file_index INTEGER NOT NULL DEFAULT 0,
original_name TEXT NOT NULL,
stored_path TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
ciphertext_size_bytes INTEGER NOT NULL,
encrypted_key_wrapped BLOB NOT NULL,
encrypted_hash BLOB NOT NULL,
render_flags INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (content_id, file_index)
);
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,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'dismissed', 'actioned')),
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
resolved_at DATETIME,
resolver_id INTEGER REFERENCES users(id)
);
CREATE TABLE admin_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_user_id INTEGER NOT NULL REFERENCES users(id),
target_type TEXT NOT NULL CHECK (target_type IN ('content', 'user')),
target_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('delete', 'blacklist', 'ignore')),
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -0,0 +1,7 @@
CREATE INDEX idx_contents_user_id ON contents(user_id);
CREATE INDEX idx_contents_status_created ON contents(status, created_at);
CREATE INDEX idx_contents_deleted_at ON contents(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_content_files_content_id ON content_files(content_id);
CREATE INDEX idx_content_files_hash ON content_files(encrypted_hash);
CREATE INDEX idx_reports_content_id ON reports(content_id);
CREATE INDEX idx_reports_status ON reports(status);