Initial commit
This commit is contained in:
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal 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
3713
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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
445
README.md
Normal 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
123
config/default.example.toml
Normal 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
|
||||||
32
crates/cgcx-bot/Cargo.toml
Normal file
32
crates/cgcx-bot/Cargo.toml
Normal 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"
|
||||||
1
crates/cgcx-bot/src/lib.rs
Normal file
1
crates/cgcx-bot/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub fn placeholder() {}
|
||||||
988
crates/cgcx-bot/src/main.rs
Normal file
988
crates/cgcx-bot/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
11
crates/cgcx-config/Cargo.toml
Normal file
11
crates/cgcx-config/Cargo.toml
Normal 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"
|
||||||
192
crates/cgcx-config/src/lib.rs
Normal file
192
crates/cgcx-config/src/lib.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/cgcx-content-typing/Cargo.toml
Normal file
9
crates/cgcx-content-typing/Cargo.toml
Normal 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"
|
||||||
87
crates/cgcx-content-typing/src/lib.rs
Normal file
87
crates/cgcx-content-typing/src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
10
crates/cgcx-core/Cargo.toml
Normal file
10
crates/cgcx-core/Cargo.toml
Normal 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"
|
||||||
75
crates/cgcx-core/src/id.rs
Normal file
75
crates/cgcx-core/src/id.rs
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
crates/cgcx-core/src/lib.rs
Normal file
37
crates/cgcx-core/src/lib.rs
Normal 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>;
|
||||||
86
crates/cgcx-core/src/models.rs
Normal file
86
crates/cgcx-core/src/models.rs
Normal 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>,
|
||||||
|
}
|
||||||
14
crates/cgcx-crypto/Cargo.toml
Normal file
14
crates/cgcx-crypto/Cargo.toml
Normal 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"
|
||||||
107
crates/cgcx-crypto/src/lib.rs
Normal file
107
crates/cgcx-crypto/src/lib.rs
Normal 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())
|
||||||
|
}
|
||||||
93
crates/cgcx-crypto/src/master_key.rs
Normal file
93
crates/cgcx-crypto/src/master_key.rs
Normal 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
13
crates/cgcx-db/Cargo.toml
Normal 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
55
crates/cgcx-db/src/lib.rs
Normal 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
389
crates/cgcx-db/src/repos.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/cgcx-file-pipeline/Cargo.toml
Normal file
17
crates/cgcx-file-pipeline/Cargo.toml
Normal 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"
|
||||||
285
crates/cgcx-file-pipeline/src/lib.rs
Normal file
285
crates/cgcx-file-pipeline/src/lib.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/cgcx-moderation/Cargo.toml
Normal file
13
crates/cgcx-moderation/Cargo.toml
Normal 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"
|
||||||
152
crates/cgcx-moderation/src/lib.rs
Normal file
152
crates/cgcx-moderation/src/lib.rs
Normal 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())
|
||||||
|
}
|
||||||
38
crates/cgcx-server/Cargo.toml
Normal file
38
crates/cgcx-server/Cargo.toml
Normal 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"
|
||||||
1
crates/cgcx-server/src/lib.rs
Normal file
1
crates/cgcx-server/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub fn placeholder() {}
|
||||||
713
crates/cgcx-server/src/main.rs
Normal file
713
crates/cgcx-server/src/main.rs
Normal 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()
|
||||||
|
}
|
||||||
11
crates/cgcx-storage/Cargo.toml
Normal file
11
crates/cgcx-storage/Cargo.toml
Normal 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"
|
||||||
86
crates/cgcx-storage/src/lib.rs
Normal file
86
crates/cgcx-storage/src/lib.rs
Normal 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
129
docs/ARCHITECTURE.md
Normal 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
13
frontend/index.html
Normal 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
19
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/fonts/press-start-2p-latin.woff2
Normal file
BIN
frontend/public/fonts/press-start-2p-latin.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/vt323-latin.woff2
Normal file
BIN
frontend/public/fonts/vt323-latin.woff2
Normal file
Binary file not shown.
29
frontend/src/App.svelte
Normal file
29
frontend/src/App.svelte
Normal 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}
|
||||||
31
frontend/src/components/AudioPlayer.svelte
Normal file
31
frontend/src/components/AudioPlayer.svelte
Normal 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>
|
||||||
59
frontend/src/components/DocumentCard.svelte
Normal file
59
frontend/src/components/DocumentCard.svelte
Normal 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>
|
||||||
56
frontend/src/components/ExecutableWarning.svelte
Normal file
56
frontend/src/components/ExecutableWarning.svelte
Normal 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>
|
||||||
22
frontend/src/components/ImageViewer.svelte
Normal file
22
frontend/src/components/ImageViewer.svelte
Normal 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>
|
||||||
160
frontend/src/components/LoadingScreen.svelte
Normal file
160
frontend/src/components/LoadingScreen.svelte
Normal 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>
|
||||||
79
frontend/src/components/MarkdownRenderer.svelte
Normal file
79
frontend/src/components/MarkdownRenderer.svelte
Normal 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>
|
||||||
82
frontend/src/components/MixedGallery.svelte
Normal file
82
frontend/src/components/MixedGallery.svelte
Normal 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>
|
||||||
46
frontend/src/components/TextViewer.svelte
Normal file
46
frontend/src/components/TextViewer.svelte
Normal 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>
|
||||||
23
frontend/src/components/VideoPlayer.svelte
Normal file
23
frontend/src/components/VideoPlayer.svelte
Normal 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
38
frontend/src/lib/api.js
Normal 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
12
frontend/src/main.js
Normal 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
|
||||||
128
frontend/src/routes/Home.svelte
Normal file
128
frontend/src/routes/Home.svelte
Normal 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 <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>
|
||||||
183
frontend/src/routes/ViewContent.svelte
Normal file
183
frontend/src/routes/ViewContent.svelte
Normal 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}><- 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>
|
||||||
124
frontend/src/styles/global.css
Normal file
124
frontend/src/styles/global.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/styles/retro-theme.css
Normal file
12
frontend/src/styles/retro-theme.css
Normal 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
10
frontend/vite.config.ts
Normal 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
58
migrations/001_init.sql
Normal 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'))
|
||||||
|
);
|
||||||
7
migrations/002_indexes.sql
Normal file
7
migrations/002_indexes.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user