Additional Bug fixes
This commit is contained in:
36
README.md
36
README.md
@@ -1,6 +1,6 @@
|
|||||||
# cg.cx
|
# cg.cx
|
||||||
|
|
||||||
> End-to-end encrypted content sharing via Telegram — with a modern web frontend.
|
> 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.
|
**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.
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ cg.cx is organized as a **Rust workspace** with 10 focused crates. This modular
|
|||||||
| `cgcx-content-typing` | MIME type detection (`infer` + `mime_guess`) and render-flag computation for safe UI handling of dangerous files. |
|
| `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-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-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-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. |
|
| `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?
|
### Why a Modular Crate Structure?
|
||||||
|
|
||||||
@@ -178,9 +178,9 @@ The static assets are emitted to `frontend/dist/` and served by `cgcx-server` at
|
|||||||
|
|
||||||
cg.cx uses a layered configuration system:
|
cg.cx uses a layered configuration system:
|
||||||
|
|
||||||
1. `config/default.toml` — committed defaults
|
1. `config/default.toml` - committed defaults
|
||||||
2. `config/default.example.toml` — local overrides (gitignored)
|
2. `config/default.example.toml` - local overrides (gitignored)
|
||||||
3. `CGCX_*` environment variables — runtime overrides
|
3. `CGCX_*` environment variables - runtime overrides
|
||||||
|
|
||||||
Environment variables use double-underscore as a separator, e.g.:
|
Environment variables use double-underscore as a separator, e.g.:
|
||||||
|
|
||||||
@@ -228,12 +228,12 @@ cargo run -p cgcx-server
|
|||||||
|
|
||||||
The server binds to `127.0.0.1:8080` by default and serves:
|
The server binds to `127.0.0.1:8080` by default and serves:
|
||||||
|
|
||||||
- `/` — Svelte frontend
|
- `/` - Svelte frontend
|
||||||
- `/api/health` — health check
|
- `/api/health` - health check
|
||||||
- `/api/content/:cxid` — metadata JSON
|
- `/api/content/:cxid` - metadata JSON
|
||||||
- `/api/content/:cxid/verify-password` — password verification
|
- `/api/content/:cxid/verify-password` - password verification
|
||||||
- `/api/content/:cxid/file/:file_idx` — streamed decrypted file
|
- `/api/content/:cxid/file/:file_idx` - streamed decrypted file
|
||||||
- `/assets/*` — static frontend assets
|
- `/assets/*` - static frontend assets
|
||||||
|
|
||||||
### Run the Telegram Bot
|
### Run the Telegram Bot
|
||||||
|
|
||||||
@@ -263,8 +263,8 @@ Ensure both processes point to the same database path and storage directories vi
|
|||||||
|
|
||||||
Migrations are managed by `rusqlite_migration` and embedded into the `cgcx-db` crate at compile time.
|
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/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.
|
- `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:
|
On startup, both the bot and server call `db.run_migrations()`, which applies any pending migrations automatically. The database is opened with:
|
||||||
|
|
||||||
@@ -369,9 +369,9 @@ Admin commands are restricted to users in configured `admin_group_ids` who also
|
|||||||
|
|
||||||
Reports submitted by users are forwarded to all configured `review_group_ids` with an inline keyboard:
|
Reports submitted by users are forwarded to all configured `review_group_ids` with an inline keyboard:
|
||||||
|
|
||||||
- **🗑 Delete** — Sets content status to `deleted`.
|
- **🗑 Delete** - Sets content status to `deleted`.
|
||||||
- **⛔ Blacklist User** — Blacklists the uploader and bans them.
|
- **⛔ Blacklist User** - Blacklists the uploader and bans them.
|
||||||
- **📝 Ignore** — Dismisses the report.
|
- **📝 Ignore** - Dismisses the report.
|
||||||
|
|
||||||
### Moderation Modes
|
### Moderation Modes
|
||||||
|
|
||||||
@@ -436,7 +436,7 @@ cargo test -p cgcx-content-typing
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License — see [LICENSE](LICENSE) for details.
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -66,20 +66,20 @@ review_group_ids = []
|
|||||||
paths = { media = "./data/media", documents = "./data/documents", text = "./data/text", temp = "./data/temp" }
|
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 for streaming upload/download. Clamped to [8 MiB, 256 MiB].
|
||||||
chunk_size_bytes = 67_108_864 # 64 MiB
|
chunk_size_bytes = 33_554_432 # = 32 MiB, 67_108_864 = 64 MiB
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Upload Limits
|
# Upload Limits
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
[upload_limits]
|
[upload_limits]
|
||||||
# Maximum number of files per content entry.
|
# Maximum number of files per content entry.
|
||||||
max_batch_size = 10
|
max_batch_size = 40
|
||||||
|
|
||||||
# Maximum size of a single file (bytes).
|
# Maximum size of a single file (bytes).
|
||||||
max_file_size_bytes = 838_860_800 # 800 MiB
|
max_file_size_bytes = 838_860_800 # 800 MiB
|
||||||
|
|
||||||
# Maximum total size of all files in one batch (bytes).
|
# Maximum total size of all files in one batch (bytes).
|
||||||
max_total_batch_bytes = 2_147_483_648 # 2 GiB
|
max_total_batch_bytes = 2_147_483_648 # = 2 GiB, 5_368_709_120 = 5 GiB
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# HTTP Server
|
# HTTP Server
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ async fn run_bot() {
|
|||||||
|
|
||||||
let config = Arc::new(Config::load().expect("Failed to load config"));
|
let config = Arc::new(Config::load().expect("Failed to load config"));
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all("data").await.ok();
|
||||||
|
|
||||||
let db = Arc::new(Database::open("data/db.sqlite").expect("Failed to open database"));
|
let db = Arc::new(Database::open("data/db.sqlite").expect("Failed to open database"));
|
||||||
db.run_migrations().await.expect("Failed to run migrations");
|
db.run_migrations().await.expect("Failed to run migrations");
|
||||||
|
|
||||||
@@ -201,6 +203,23 @@ async fn handle_message(
|
|||||||
msg: Message,
|
msg: Message,
|
||||||
storage: Arc<InMemStorage<BotState>>,
|
storage: Arc<InMemStorage<BotState>>,
|
||||||
ctx: BotContext,
|
ctx: BotContext,
|
||||||
|
) -> HandlerResult {
|
||||||
|
let chat_id = msg.chat.id;
|
||||||
|
if let Err(e) = handle_message_inner(bot.clone(), msg, storage, ctx).await {
|
||||||
|
tracing::error!("handle_message error: {}", e);
|
||||||
|
let _ = bot.send_message(chat_id, "<b>An error occurred.</b> Please try again.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
|
async fn handle_message_inner(
|
||||||
|
bot: Bot,
|
||||||
|
msg: Message,
|
||||||
|
storage: Arc<InMemStorage<BotState>>,
|
||||||
|
ctx: BotContext,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let user = match &msg.from {
|
let user = match &msg.from {
|
||||||
Some(u) => u.clone(),
|
Some(u) => u.clone(),
|
||||||
@@ -219,7 +238,9 @@ async fn handle_message(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if matches!(db_user.role, UserRole::Banned) || !ctx.moderation.is_allowed(user_id).await {
|
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?;
|
bot.send_message(chat_id, "<b>[ Banned ]</b> You are not allowed to use this service.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
dialogue.exit().await?;
|
dialogue.exit().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -232,7 +253,9 @@ async fn handle_message(
|
|||||||
"/reload" => {
|
"/reload" => {
|
||||||
if is_admin(&bot, msg.chat.id, user.id).await {
|
if is_admin(&bot, msg.chat.id, user.id).await {
|
||||||
ctx.moderation.load().await?;
|
ctx.moderation.load().await?;
|
||||||
bot.send_message(chat_id, "Moderation lists reloaded.").await?;
|
bot.send_message(chat_id, "<b>Moderation lists reloaded.</b>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -312,6 +335,23 @@ async fn handle_callback(
|
|||||||
q: CallbackQuery,
|
q: CallbackQuery,
|
||||||
storage: Arc<InMemStorage<BotState>>,
|
storage: Arc<InMemStorage<BotState>>,
|
||||||
ctx: BotContext,
|
ctx: BotContext,
|
||||||
|
) -> HandlerResult {
|
||||||
|
let chat_id = q.message.as_ref().map(|m| m.chat().id).unwrap_or(ChatId(q.from.id.0 as i64));
|
||||||
|
if let Err(e) = handle_callback_inner(bot.clone(), q, storage, ctx).await {
|
||||||
|
tracing::error!("handle_callback error: {}", e);
|
||||||
|
let _ = bot.send_message(chat_id, "<b>An error occurred.</b> Please try again.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(never)]
|
||||||
|
async fn handle_callback_inner(
|
||||||
|
bot: Bot,
|
||||||
|
q: CallbackQuery,
|
||||||
|
storage: Arc<InMemStorage<BotState>>,
|
||||||
|
ctx: BotContext,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
// CallbackQuery (and the Message it may contain) are very large structs.
|
// CallbackQuery (and the Message it may contain) are very large structs.
|
||||||
// Extract only the fields we need and drop q before the first .await so
|
// Extract only the fields we need and drop q before the first .await so
|
||||||
@@ -323,8 +363,10 @@ async fn handle_callback(
|
|||||||
let message_id = q.message.as_ref().map(|m| m.id());
|
let message_id = q.message.as_ref().map(|m| m.id());
|
||||||
drop(q);
|
drop(q);
|
||||||
|
|
||||||
|
bot.answer_callback_query(&callback_id).await.ok();
|
||||||
|
|
||||||
if !ctx.moderation.is_allowed(user_id).await {
|
if !ctx.moderation.is_allowed(user_id).await {
|
||||||
bot.answer_callback_query(&callback_id).text("Not allowed").await?;
|
bot.answer_callback_query(&callback_id).text("Not allowed").await.ok();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +374,7 @@ async fn handle_callback(
|
|||||||
|
|
||||||
let parts: Vec<&str> = data.split(':').collect();
|
let parts: Vec<&str> = data.split(':').collect();
|
||||||
if parts.len() < 3 || parts[0] != "v1" {
|
if parts.len() < 3 || parts[0] != "v1" {
|
||||||
bot.answer_callback_query(&callback_id).await?;
|
bot.answer_callback_query(&callback_id).await.ok();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,15 +399,15 @@ async fn handle_callback(
|
|||||||
"menu" => match parts[2] {
|
"menu" => match parts[2] {
|
||||||
"upload_media" => {
|
"upload_media" => {
|
||||||
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
|
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
|
||||||
send_staging_message(&bot, chat_id, &[], UploadType::Media).await?;
|
send_staging_message(&bot, chat_id, &[], UploadType::Media, ctx.config.upload_limits.max_batch_size).await?;
|
||||||
}
|
}
|
||||||
"upload_doc" => {
|
"upload_doc" => {
|
||||||
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document }).await?;
|
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document }).await?;
|
||||||
send_staging_message(&bot, chat_id, &[], UploadType::Document).await?;
|
send_staging_message(&bot, chat_id, &[], UploadType::Document, ctx.config.upload_limits.max_batch_size).await?;
|
||||||
}
|
}
|
||||||
"upload_text" => {
|
"upload_text" => {
|
||||||
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text }).await?;
|
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text }).await?;
|
||||||
send_staging_message(&bot, chat_id, &[], UploadType::Text).await?;
|
send_staging_message(&bot, chat_id, &[], UploadType::Text, ctx.config.upload_limits.max_batch_size).await?;
|
||||||
}
|
}
|
||||||
"prev_uploads" => {
|
"prev_uploads" => {
|
||||||
dialogue.update(BotState::ViewingPrevious { page: 0 }).await?;
|
dialogue.update(BotState::ViewingPrevious { page: 0 }).await?;
|
||||||
@@ -373,7 +415,9 @@ async fn handle_callback(
|
|||||||
}
|
}
|
||||||
"report" => {
|
"report" => {
|
||||||
dialogue.update(BotState::Reporting).await?;
|
dialogue.update(BotState::Reporting).await?;
|
||||||
bot.send_message(chat_id, "Send me the content link or content ID to report.").await?;
|
bot.send_message(chat_id, "Send me the <code>content link</code> or <code>content ID</code> to report.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
"main" => {
|
"main" => {
|
||||||
send_main_menu(&bot, chat_id, &dialogue).await?;
|
send_main_menu(&bot, chat_id, &dialogue).await?;
|
||||||
@@ -385,7 +429,7 @@ async fn handle_callback(
|
|||||||
let state = dialogue.get_or_default().await?;
|
let state = dialogue.get_or_default().await?;
|
||||||
if let BotState::UploadStaging { items, .. } = state {
|
if let BotState::UploadStaging { items, .. } = state {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
bot.answer_callback_query(&callback_id).text("No items to upload.").await?;
|
bot.answer_callback_query(&callback_id).text("No items to upload.").await.ok();
|
||||||
} else {
|
} else {
|
||||||
let options = UploadOptions {
|
let options = UploadOptions {
|
||||||
allow_download: true,
|
allow_download: true,
|
||||||
@@ -398,7 +442,9 @@ async fn handle_callback(
|
|||||||
}
|
}
|
||||||
"cancel" => {
|
"cancel" => {
|
||||||
if let Some(mid) = message_id {
|
if let Some(mid) = message_id {
|
||||||
bot.edit_message_text(chat_id, mid, "Upload cancelled.").await.ok();
|
bot.edit_message_text(chat_id, mid, "<i>Upload cancelled.</i>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await.ok();
|
||||||
}
|
}
|
||||||
dialogue.update(BotState::MainMenu).await?;
|
dialogue.update(BotState::MainMenu).await?;
|
||||||
}
|
}
|
||||||
@@ -425,7 +471,9 @@ async fn handle_callback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"set_password" => {
|
"set_password" => {
|
||||||
bot.send_message(chat_id, "Send the password (max 32 chars) or /skip to skip.").await?;
|
bot.send_message(chat_id, "Send the <code>password</code> (max <b>32</b> chars) or <code>/skip</code> to skip.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
"confirm_final" => {
|
"confirm_final" => {
|
||||||
let state = dialogue.get_or_default().await?;
|
let state = dialogue.get_or_default().await?;
|
||||||
@@ -436,7 +484,7 @@ async fn handle_callback(
|
|||||||
}
|
}
|
||||||
"back" => {
|
"back" => {
|
||||||
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
|
dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?;
|
||||||
send_staging_message(&bot, chat_id, &[], UploadType::Media).await?;
|
send_staging_message(&bot, chat_id, &[], UploadType::Media, ctx.config.upload_limits.max_batch_size).await?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
@@ -456,7 +504,6 @@ async fn handle_callback(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.answer_callback_query(&callback_id).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,30 +528,31 @@ async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> H
|
|||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
InlineKeyboardButton::callback("[ Upload Text ]", "v1:menu:upload_text"),
|
InlineKeyboardButton::callback("[ Upload Text ]", "v1:menu:upload_text"),
|
||||||
InlineKeyboardButton::callback("[ Previous Uploads ]", "v1:menu:prev_uploads"),
|
InlineKeyboardButton::callback("[ My Uploads ]", "v1:menu:prev_uploads"),
|
||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
InlineKeyboardButton::callback("[ Report Content ]", "v1:menu:report"),
|
InlineKeyboardButton::callback("[ Report Content ]", "v1:menu:report"),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
bot.send_message(chat_id, "Choose from the menu below. Administrators can be contacted here: @harmfulmeowbot")
|
bot.send_message(chat_id, "<b>Main Menu</b>\n\nChoose from the menu below.\n<i>Administrators can be contacted here:</i> <a href=\"https://t.me/harmfulmeowbot?start=submit\">t.me/harmfulmeowbot?start=submit</a>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
.reply_markup(keyboard)
|
.reply_markup(keyboard)
|
||||||
.await?;
|
.await?;
|
||||||
dialogue.update(BotState::MainMenu).await?;
|
dialogue.update(BotState::MainMenu).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem], upload_type: UploadType) -> HandlerResult {
|
async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem], upload_type: UploadType, max_batch_size: usize) -> HandlerResult {
|
||||||
let type_label = match upload_type {
|
let type_label = match upload_type {
|
||||||
UploadType::Media => "Media",
|
UploadType::Media => "Media",
|
||||||
UploadType::Document => "Documents",
|
UploadType::Document => "Documents",
|
||||||
UploadType::Text => "Text",
|
UploadType::Text => "Text",
|
||||||
};
|
};
|
||||||
let text = if items.is_empty() {
|
let text = if items.is_empty() {
|
||||||
format!("[ Staging {} (0/10) ]\n\nSend me files to add them.", type_label)
|
format!("<b>[ Staging {} ]</b>\n\n<i>Send me files to add them.</i>\n\n<code>0/{}</code> items", type_label, max_batch_size)
|
||||||
} else {
|
} else {
|
||||||
let list: String = items.iter().map(|i| format!("- {}\n", i.file_name)).collect();
|
let list: String = items.iter().map(|i| format!("• <code>{}</code>\n", i.file_name)).collect();
|
||||||
format!("[ Staging {} ({}/10) ]\n\n{}", type_label, items.len(), list)
|
format!("<b>[ Staging {} ]</b> <code>{}/{}</code>\n\n{}", type_label, items.len(), max_batch_size, list)
|
||||||
};
|
};
|
||||||
|
|
||||||
let keyboard = InlineKeyboardMarkup::new(vec![vec![
|
let keyboard = InlineKeyboardMarkup::new(vec![vec![
|
||||||
@@ -513,6 +561,7 @@ async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem],
|
|||||||
]]);
|
]]);
|
||||||
|
|
||||||
bot.send_message(chat_id, text)
|
bot.send_message(chat_id, text)
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
.reply_markup(keyboard)
|
.reply_markup(keyboard)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -528,7 +577,9 @@ async fn handle_staging_message(
|
|||||||
upload_type: UploadType,
|
upload_type: UploadType,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
if items.len() >= ctx.config.upload_limits.max_batch_size {
|
if items.len() >= ctx.config.upload_limits.max_batch_size {
|
||||||
bot.send_message(msg.chat.id, "Maximum batch size reached.").await?;
|
bot.send_message(msg.chat.id, "<b>Maximum batch size reached.</b>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +649,7 @@ async fn handle_staging_message(
|
|||||||
if let Some(item) = new_item {
|
if let Some(item) = new_item {
|
||||||
items.push(item);
|
items.push(item);
|
||||||
dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type }).await?;
|
dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type }).await?;
|
||||||
send_staging_message(bot, chat_id, &items, upload_type).await?;
|
send_staging_message(bot, chat_id, &items, upload_type, ctx.config.upload_limits.max_batch_size).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -611,22 +662,22 @@ async fn refresh_options_message(
|
|||||||
options: &UploadOptions,
|
options: &UploadOptions,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let destroy_text = match options.max_views {
|
let destroy_text = match options.max_views {
|
||||||
Some(n) => format!("Auto-destroy: {} views", n),
|
Some(n) => format!("Auto-destroy: <b>{}</b> views", n),
|
||||||
None => "Auto-destroy: Off".to_string(),
|
None => "Auto-destroy: <i>Off</i>".to_string(),
|
||||||
};
|
};
|
||||||
let download_text = if options.allow_download {
|
let download_text = if options.allow_download {
|
||||||
"Allow download: Yes"
|
"Allow download: <b>Yes</b>"
|
||||||
} else {
|
} else {
|
||||||
"Allow download: No"
|
"Allow download: <b>No</b>"
|
||||||
};
|
};
|
||||||
let password_text = if options.password.is_some() {
|
let password_text = if options.password.is_some() {
|
||||||
"Password: Set"
|
"Password: <b>Set</b>"
|
||||||
} else {
|
} else {
|
||||||
"Password: None"
|
"Password: <i>None</i>"
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.",
|
"<b>[ Upload Options ]</b>\n\n{}\n{}\n{}\n\n<i>Confirm when ready.</i>",
|
||||||
destroy_text, download_text, password_text
|
destroy_text, download_text, password_text
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -640,11 +691,12 @@ async fn refresh_options_message(
|
|||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"),
|
InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"),
|
||||||
InlineKeyboardButton::callback("[ Confirm & Upload ]", "v1:opt:confirm_final"),
|
InlineKeyboardButton::callback("[ Confirm ]", "v1:opt:confirm_final"),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
bot.send_message(chat_id, text)
|
bot.send_message(chat_id, text)
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
.reply_markup(keyboard)
|
.reply_markup(keyboard)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -659,11 +711,15 @@ async fn finalize_upload(
|
|||||||
dialogue: &BotDialogue,
|
dialogue: &BotDialogue,
|
||||||
ctx: &BotContext,
|
ctx: &BotContext,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]").await?;
|
let status_msg = bot.send_message(chat_id, "<i>[ Encrypting and storing... ]</i>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let total_size: u64 = items.iter().map(|i| i.size).sum();
|
let total_size: u64 = items.iter().map(|i| i.size).sum();
|
||||||
if total_size > ctx.config.upload_limits.max_total_batch_bytes {
|
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?;
|
bot.edit_message_text(chat_id, status_msg.id, "<b>[ Error ]</b> Total batch size exceeds limit.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
dialogue.update(BotState::MainMenu).await?;
|
dialogue.update(BotState::MainMenu).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -672,7 +728,9 @@ async fn finalize_upload(
|
|||||||
if let Ok(temp_path) = std::fs::canonicalize(&ctx.config.storage.paths.temp) {
|
if let Ok(temp_path) = std::fs::canonicalize(&ctx.config.storage.paths.temp) {
|
||||||
if let Ok(info) = fs2::available_space(&temp_path) {
|
if let Ok(info) = fs2::available_space(&temp_path) {
|
||||||
if info < total_size * 2 {
|
if info < total_size * 2 {
|
||||||
bot.edit_message_text(chat_id, status_msg.id, "[ Error: insufficient storage space. ]").await?;
|
bot.edit_message_text(chat_id, status_msg.id, "<b>[ Error ]</b> Insufficient storage space.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
dialogue.update(BotState::MainMenu).await?;
|
dialogue.update(BotState::MainMenu).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -767,10 +825,14 @@ async fn finalize_upload(
|
|||||||
attrs.join(", ")
|
attrs.join(", ")
|
||||||
};
|
};
|
||||||
|
|
||||||
let result_text = format!(
|
let mut result_text = format!(
|
||||||
"[ Upload Complete ]\n\nLink: <code>{}</code>\n\nFiles: {} | {}",
|
"<b>[ Upload Complete ]</b>\n\nLink: <code>{}</code>\n\nFiles: <b>{}</b> | <i>{}</i>",
|
||||||
link, items.len(), attr_text
|
link, items.len(), attr_text
|
||||||
);
|
);
|
||||||
|
if let Some(ref pw) = options.password {
|
||||||
|
let direct_link = format!("{}/?cxid={}&sc={}", base_url, content_id.as_str(), pw);
|
||||||
|
result_text.push_str(&format!("\n\n<i>Direct Access Link:</i> <code>{}</code>", direct_link));
|
||||||
|
}
|
||||||
|
|
||||||
bot.edit_message_text(chat_id, status_msg.id, result_text)
|
bot.edit_message_text(chat_id, status_msg.id, result_text)
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
@@ -793,12 +855,14 @@ async fn show_previous_uploads(
|
|||||||
let total_pages = (total + 9) / 10;
|
let total_pages = (total + 9) / 10;
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
bot.send_message(chat_id, "You have no uploads.").await?;
|
bot.send_message(chat_id, "<i>You have no uploads.</i>")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_url = &ctx.config.server.base_url;
|
let base_url = &ctx.config.server.base_url;
|
||||||
let mut text = format!("[ Your Uploads ] Page {}/{}\n\n", page + 1, total_pages.max(1));
|
let mut text = format!("<b>[ My Uploads ]</b> Page {}/{}\n\n", page + 1, total_pages.max(1));
|
||||||
for content in &items {
|
for content in &items {
|
||||||
let file_repo = ContentFileRepo::new(ctx.db.conn());
|
let file_repo = ContentFileRepo::new(ctx.db.conn());
|
||||||
let files = file_repo.list_by_content(&content.id).await?;
|
let files = file_repo.list_by_content(&content.id).await?;
|
||||||
@@ -814,10 +878,28 @@ async fn show_previous_uploads(
|
|||||||
}
|
}
|
||||||
let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") };
|
let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") };
|
||||||
|
|
||||||
|
if content.password_hash.is_some() {
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
"- <code>{}</code> ({} files) [{}]\n {}?cxid={}\n\n",
|
"• <code>{}</code> ({} files) [{}]\n {}?cxid={} <i>(password protected)</i>\n",
|
||||||
content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str()
|
content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str()
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"• <code>{}</code> ({} files) [{}]\n {}?cxid={}\n",
|
||||||
|
content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut keyboard_rows = vec![];
|
||||||
|
for content in &items {
|
||||||
|
keyboard_rows.push(vec![
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
format!("[ Del {} ]", content.id.as_str()),
|
||||||
|
format!("v1:admin:delcontent:{}", content.id.as_str())
|
||||||
|
)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut buttons = vec![];
|
let mut buttons = vec![];
|
||||||
@@ -828,10 +910,12 @@ async fn show_previous_uploads(
|
|||||||
if page + 1 < total_pages {
|
if page + 1 < total_pages {
|
||||||
buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1)));
|
buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1)));
|
||||||
}
|
}
|
||||||
|
keyboard_rows.push(buttons);
|
||||||
let keyboard = InlineKeyboardMarkup::new(vec![buttons, vec![
|
keyboard_rows.push(vec![
|
||||||
InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"),
|
InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"),
|
||||||
]]);
|
]);
|
||||||
|
|
||||||
|
let keyboard = InlineKeyboardMarkup::new(keyboard_rows);
|
||||||
|
|
||||||
bot.send_message(chat_id, text)
|
bot.send_message(chat_id, text)
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
@@ -860,7 +944,7 @@ async fn handle_report(
|
|||||||
|
|
||||||
for &group_id in &ctx.config.groups.review_group_ids {
|
for &group_id in &ctx.config.groups.review_group_ids {
|
||||||
let report_text = format!(
|
let report_text = format!(
|
||||||
"[ NEW REPORT ] #{}\n\nCXID: <code>{}</code>\nReporter: <code>{}</code>\nOwner: <code>{}</code>\nUploaded: {}\nFiles: {}",
|
"<b>[ NEW REPORT ]</b> #{}\n\nCXID: <code>{}</code>\nReporter: <code>{}</code>\nOwner: <code>{}</code>\nUploaded: <i>{}</i>\nFiles: <b>{}</b>",
|
||||||
report_id,
|
report_id,
|
||||||
cxid,
|
cxid,
|
||||||
reporter_id,
|
reporter_id,
|
||||||
@@ -871,7 +955,7 @@ async fn handle_report(
|
|||||||
|
|
||||||
let keyboard = InlineKeyboardMarkup::new(vec![
|
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
vec![
|
vec![
|
||||||
InlineKeyboardButton::callback("[ Delete + Blacklist ]", format!("v1:admin:delblk:{}", report_id)),
|
InlineKeyboardButton::callback("[ Rmv + Ban ]", format!("v1:admin:delblk:{}", report_id)),
|
||||||
InlineKeyboardButton::callback("[ Delete Only ]", format!("v1:admin:del:{}", report_id)),
|
InlineKeyboardButton::callback("[ Delete Only ]", format!("v1:admin:del:{}", report_id)),
|
||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
@@ -887,7 +971,9 @@ async fn handle_report(
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.").await?;
|
bot.send_message(chat_id, "<b>Report submitted.</b> Moderators will review it shortly.")
|
||||||
|
.parse_mode(ParseMode::Html)
|
||||||
|
.await?;
|
||||||
dialogue.update(BotState::MainMenu).await?;
|
dialogue.update(BotState::MainMenu).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -899,8 +985,37 @@ async fn handle_admin_callback(
|
|||||||
parts: &[&str],
|
parts: &[&str],
|
||||||
ctx: &BotContext,
|
ctx: &BotContext,
|
||||||
) -> HandlerResult {
|
) -> HandlerResult {
|
||||||
|
match parts[2] {
|
||||||
|
"delcontent" => {
|
||||||
|
let cxid = parts[3];
|
||||||
|
let content_id = ContentId::try_from(cxid)?;
|
||||||
|
let content_repo = ContentRepo::new(ctx.db.conn());
|
||||||
|
let content = match content_repo.get(&content_id).await? {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
bot.send_message(chat_id, "<b>Content not found.</b>")
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let is_admin = is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await;
|
||||||
|
if !is_admin && content.user_id != user_id {
|
||||||
|
bot.send_message(chat_id, "<b>Unauthorized.</b>")
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ctx.pipeline.delete_content(&content_id, ctx.config.content.keep_content).await.ok();
|
||||||
|
content_repo.set_status(&content_id, ContentStatus::Deleted).await.ok();
|
||||||
|
bot.send_message(chat_id, format!("Content <code>{}</code> deleted.", cxid))
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
if !is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await {
|
if !is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await {
|
||||||
bot.send_message(chat_id, "Unauthorized.").await?;
|
bot.send_message(chat_id, "<b>Unauthorized.</b>")
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,7 +1024,8 @@ async fn handle_admin_callback(
|
|||||||
let report = match report_repo.get(report_id).await? {
|
let report = match report_repo.get(report_id).await? {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => {
|
None => {
|
||||||
bot.send_message(chat_id, "Report not found.").await?;
|
bot.send_message(chat_id, "<b>Report not found.</b>")
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -918,27 +1034,28 @@ async fn handle_admin_callback(
|
|||||||
let content = match content_repo.get(&report.content_id).await? {
|
let content = match content_repo.get(&report.content_id).await? {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
bot.send_message(chat_id, "Content not found.").await?;
|
bot.send_message(chat_id, "<b>Content not found.</b>")
|
||||||
|
.parse_mode(ParseMode::Html).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match parts[2] {
|
match parts[2] {
|
||||||
"delblk" => {
|
"delblk" => {
|
||||||
ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok();
|
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();
|
content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok();
|
||||||
ctx.moderation.blacklist(content.user_id).await.ok();
|
ctx.moderation.blacklist(content.user_id).await.ok();
|
||||||
let user_repo = UserRepo::new(ctx.db.conn());
|
let user_repo = UserRepo::new(ctx.db.conn());
|
||||||
user_repo.set_role(content.user_id, "banned").await.ok();
|
user_repo.set_role(content.user_id, "banned").await.ok();
|
||||||
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).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))
|
bot.send_message(chat_id, format!("Deleted content <code>{}</code> and blacklisted user <code>{}</code>", report.content_id.as_str(), content.user_id))
|
||||||
.parse_mode(ParseMode::Html).await?;
|
.parse_mode(ParseMode::Html).await?;
|
||||||
}
|
}
|
||||||
"del" => {
|
"del" => {
|
||||||
ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok();
|
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();
|
content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok();
|
||||||
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).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()))
|
bot.send_message(chat_id, format!("Deleted content <code>{}</code>", report.content_id.as_str()))
|
||||||
.parse_mode(ParseMode::Html).await?;
|
.parse_mode(ParseMode::Html).await?;
|
||||||
}
|
}
|
||||||
"blk" => {
|
"blk" => {
|
||||||
@@ -946,12 +1063,12 @@ async fn handle_admin_callback(
|
|||||||
let user_repo = UserRepo::new(ctx.db.conn());
|
let user_repo = UserRepo::new(ctx.db.conn());
|
||||||
user_repo.set_role(content.user_id, "banned").await.ok();
|
user_repo.set_role(content.user_id, "banned").await.ok();
|
||||||
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok();
|
report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok();
|
||||||
bot.send_message(chat_id, format!("Blacklisted user {}", content.user_id))
|
bot.send_message(chat_id, format!("Blacklisted user <code>{}</code>", content.user_id))
|
||||||
.parse_mode(ParseMode::Html).await?;
|
.parse_mode(ParseMode::Html).await?;
|
||||||
}
|
}
|
||||||
"ign" => {
|
"ign" => {
|
||||||
report_repo.resolve(report_id, ReportStatus::Dismissed, user_id).await.ok();
|
report_repo.resolve(report_id, ReportStatus::Dismissed, user_id).await.ok();
|
||||||
bot.send_message(chat_id, format!("Ignored report #{}", report_id))
|
bot.send_message(chat_id, format!("Ignored report <code>#{}</code>", report_id))
|
||||||
.parse_mode(ParseMode::Html).await?;
|
.parse_mode(ParseMode::Html).await?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -141,11 +141,20 @@ struct IdListFile {
|
|||||||
|
|
||||||
async fn load_id_set(path: &Path) -> Result<HashSet<i64>> {
|
async fn load_id_set(path: &Path) -> Result<HashSet<i64>> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
tokio::fs::write(path, "[]")
|
||||||
|
.await
|
||||||
|
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
|
||||||
return Ok(HashSet::new());
|
return Ok(HashSet::new());
|
||||||
}
|
}
|
||||||
let json = tokio::fs::read_to_string(path)
|
let json = tokio::fs::read_to_string(path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
|
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Accept either a plain array or the IdListFile object format
|
||||||
|
if let Ok(ids) = serde_json::from_str::<Vec<i64>>(&json) {
|
||||||
|
return Ok(ids.into_iter().collect());
|
||||||
|
}
|
||||||
|
|
||||||
let file: IdListFile = serde_json::from_str(&json)
|
let file: IdListFile = serde_json::from_str(&json)
|
||||||
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
|
.map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?;
|
||||||
Ok(file.ids.into_iter().collect())
|
Ok(file.ids.into_iter().collect())
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use tower_http::{
|
|||||||
catch_panic::CatchPanicLayer,
|
catch_panic::CatchPanicLayer,
|
||||||
compression::CompressionLayer,
|
compression::CompressionLayer,
|
||||||
cors::{AllowOrigin, CorsLayer},
|
cors::{AllowOrigin, CorsLayer},
|
||||||
services::{ServeDir, ServeFile},
|
services::ServeDir,
|
||||||
timeout::TimeoutLayer,
|
timeout::TimeoutLayer,
|
||||||
trace::TraceLayer,
|
trace::TraceLayer,
|
||||||
};
|
};
|
||||||
@@ -71,6 +71,14 @@ struct VerifyPasswordRequest {
|
|||||||
struct FileQuery {
|
struct FileQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
download: bool,
|
download: bool,
|
||||||
|
#[serde(rename = "sc", default)]
|
||||||
|
sc: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ScQuery {
|
||||||
|
#[serde(rename = "sc", default)]
|
||||||
|
sc: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ByteRange {
|
struct ByteRange {
|
||||||
@@ -88,16 +96,21 @@ impl From<CgcxError> for AppError {
|
|||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, msg) = match self.0 {
|
let (status, msg) = match &self.0 {
|
||||||
CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
|
CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
|
||||||
CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||||
CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
|
CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
|
||||||
CgcxError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.as_str()),
|
CgcxError::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request"),
|
||||||
|
CgcxError::InvalidContentId(_) => (StatusCode::BAD_REQUEST, "Bad request"),
|
||||||
CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"),
|
CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"),
|
||||||
CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"),
|
CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"),
|
||||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
|
other => {
|
||||||
|
tracing::error!("Internal server error: {}", other);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
(status, msg.to_string()).into_response()
|
let body = serde_json::json!({ "error": msg });
|
||||||
|
(status, [(header::CONTENT_TYPE, "application/json")], body.to_string()).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +136,8 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
let config = Arc::new(Config::load()?);
|
let config = Arc::new(Config::load()?);
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all("data").await.ok();
|
||||||
|
|
||||||
let db = Arc::new(Database::open("data/db.sqlite")?);
|
let db = Arc::new(Database::open("data/db.sqlite")?);
|
||||||
db.run_migrations().await?;
|
db.run_migrations().await?;
|
||||||
|
|
||||||
@@ -171,8 +186,7 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
config: Arc::new(password_governor_conf),
|
config: Arc::new(password_governor_conf),
|
||||||
});
|
});
|
||||||
|
|
||||||
let static_service = ServeDir::new("frontend/dist")
|
let static_service = ServeDir::new("frontend/dist/assets");
|
||||||
.fallback(ServeFile::new("frontend/dist/index.html"));
|
|
||||||
|
|
||||||
let mut origins: Vec<HeaderValue> = vec![
|
let mut origins: Vec<HeaderValue> = vec![
|
||||||
config.server.base_url.parse().expect("invalid server.base_url"),
|
config.server.base_url.parse().expect("invalid server.base_url"),
|
||||||
@@ -215,12 +229,12 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
.route("/api/content/{cxid}", get(get_metadata))
|
.route("/api/content/{cxid}", get(get_metadata))
|
||||||
.route("/api/content/{cxid}/file/{file_idx}", get(serve_file))
|
.route("/api/content/{cxid}/file/{file_idx}", get(serve_file))
|
||||||
.merge(password_route)
|
.merge(password_route)
|
||||||
.fallback_service(static_service)
|
.nest_service("/assets", static_service)
|
||||||
|
.fallback(fallback)
|
||||||
.layer(tower_governor::GovernorLayer {
|
.layer(tower_governor::GovernorLayer {
|
||||||
config: Arc::new(governor_conf),
|
config: Arc::new(governor_conf),
|
||||||
})
|
})
|
||||||
.layer(compression)
|
.layer(compression)
|
||||||
.layer(cors)
|
|
||||||
.layer(axum::middleware::from_fn(security_headers))
|
.layer(axum::middleware::from_fn(security_headers))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(TimeoutLayer::with_status_code(
|
.layer(TimeoutLayer::with_status_code(
|
||||||
@@ -228,6 +242,7 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
))
|
))
|
||||||
.layer(CatchPanicLayer::new())
|
.layer(CatchPanicLayer::new())
|
||||||
|
.layer(cors)
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
// Spawn background sweeper task
|
// Spawn background sweeper task
|
||||||
@@ -259,6 +274,18 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fallback(uri: axum::http::Uri) -> Response {
|
||||||
|
let path = uri.path();
|
||||||
|
tracing::info!("fallback: path={}", path);
|
||||||
|
if path.starts_with("/api/") {
|
||||||
|
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Not found"}))).into_response();
|
||||||
|
}
|
||||||
|
match tokio::fs::read_to_string("frontend/dist/index.html").await {
|
||||||
|
Ok(html) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/html")], html).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Frontend not built").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Response {
|
async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Response {
|
||||||
let mut response = next.run(req).await;
|
let mut response = next.run(req).await;
|
||||||
let headers = response.headers_mut();
|
let headers = response.headers_mut();
|
||||||
@@ -281,21 +308,56 @@ async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> impl IntoResponse {
|
async fn health() -> impl IntoResponse {
|
||||||
|
tracing::info!("health");
|
||||||
axum::Json(HealthResponse {
|
axum::Json(HealthResponse {
|
||||||
status: "ok".into(),
|
status: "ok".into(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn password_from_request(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
query_sc: Option<&str>,
|
||||||
|
cxid: &str,
|
||||||
|
password_hash: Option<&str>,
|
||||||
|
cookie_secret: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
if let Some(sc) = query_sc {
|
||||||
|
if let Some(hash) = password_hash {
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
||||||
|
if Argon2::default().verify_password(sc.as_bytes(), &parsed_hash).is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("cgcx_pw=") && verify_cookie(cxid, &part[8..], cookie_secret)
|
||||||
|
})
|
||||||
|
}).unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_metadata(
|
async fn get_metadata(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(cxid): Path<String>,
|
Path(cxid): Path<String>,
|
||||||
|
Query(query): Query<ScQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
|
tracing::info!("get_metadata: cxid={}", cxid);
|
||||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||||
let repo = ContentRepo::new(state.db.conn());
|
let repo = ContentRepo::new(state.db.conn());
|
||||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||||
|
|
||||||
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
||||||
|
tracing::warn!("get_metadata returning NotFound for cxid={}", cxid);
|
||||||
return Err(CgcxError::NotFound.into());
|
return Err(CgcxError::NotFound.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,23 +366,13 @@ async fn get_metadata(
|
|||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::GONE)
|
.status(StatusCode::GONE)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if content.password_hash.is_some() {
|
if content.password_hash.is_some() {
|
||||||
let cookie_valid = headers
|
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
||||||
.get_all(header::COOKIE)
|
tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid);
|
||||||
.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());
|
return Err(CgcxError::Unauthorized.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,12 +394,12 @@ async fn get_metadata(
|
|||||||
current_views: content.view_count,
|
current_views: content.view_count,
|
||||||
allow_download: content.allow_download,
|
allow_download: content.allow_download,
|
||||||
created_at: content.created_at.to_rfc3339(),
|
created_at: content.created_at.to_rfc3339(),
|
||||||
}).map_err(|e| CgcxError::BadRequest(format!("json serialization: {}", e)))?;
|
}).map_err(|_| CgcxError::BadRequest("json serialization".into()))?;
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.body(Body::from(body))
|
.body(Body::from(body))
|
||||||
.unwrap())
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify_password(
|
async fn verify_password(
|
||||||
@@ -355,6 +407,7 @@ async fn verify_password(
|
|||||||
Path(cxid): Path<String>,
|
Path(cxid): Path<String>,
|
||||||
Json(req): Json<VerifyPasswordRequest>,
|
Json(req): Json<VerifyPasswordRequest>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
tracing::info!("verify_password: cxid={}", cxid);
|
||||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||||
let repo = ContentRepo::new(state.db.conn());
|
let repo = ContentRepo::new(state.db.conn());
|
||||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||||
@@ -363,7 +416,7 @@ async fn verify_password(
|
|||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
};
|
};
|
||||||
|
|
||||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
@@ -373,12 +426,13 @@ async fn verify_password(
|
|||||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if !valid {
|
if !valid {
|
||||||
|
tracing::warn!("verify_password returning Unauthorized for cxid={}", cxid);
|
||||||
return Err(CgcxError::Unauthorized.into());
|
return Err(CgcxError::Unauthorized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let cookie_value = make_cookie_value(&cxid, &state.cookie_secret);
|
let cookie_value = make_cookie_value(&cxid, &state.cookie_secret);
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"__Host-pw={}; Max-Age=3600; SameSite=Strict; Secure; HttpOnly; Path=/",
|
"cgcx_pw={}; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/",
|
||||||
cookie_value
|
cookie_value
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -386,7 +440,7 @@ async fn verify_password(
|
|||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.header(header::SET_COOKIE, cookie)
|
.header(header::SET_COOKIE, cookie)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_file(
|
async fn serve_file(
|
||||||
@@ -395,11 +449,13 @@ async fn serve_file(
|
|||||||
Query(query): Query<FileQuery>,
|
Query(query): Query<FileQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
tracing::info!("serve_file: cxid={} file_idx={}", cxid, file_idx);
|
||||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||||
let repo = ContentRepo::new(state.db.conn());
|
let repo = ContentRepo::new(state.db.conn());
|
||||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||||
|
|
||||||
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
||||||
|
tracing::warn!("serve_file returning NotFound for cxid={}", cxid);
|
||||||
return Err(CgcxError::NotFound.into());
|
return Err(CgcxError::NotFound.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,28 +464,19 @@ async fn serve_file(
|
|||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::GONE)
|
.status(StatusCode::GONE)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if content.password_hash.is_some() {
|
if content.password_hash.is_some() {
|
||||||
let cookie_valid = headers
|
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
||||||
.get_all(header::COOKIE)
|
tracing::warn!("serve_file returning Unauthorized for cxid={}", cxid);
|
||||||
.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());
|
return Err(CgcxError::Unauthorized.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.download && !content.allow_download {
|
if query.download && !content.allow_download {
|
||||||
|
tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={}", cxid);
|
||||||
return Err(CgcxError::Forbidden.into());
|
return Err(CgcxError::Forbidden.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +484,27 @@ async fn serve_file(
|
|||||||
let files = file_repo.list_by_content(&content_id).await?;
|
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)?;
|
let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?;
|
||||||
|
|
||||||
|
// Handle zero-size files early to avoid underflow in range parsing
|
||||||
|
if file.size_bytes == 0 {
|
||||||
|
let etag = format!("\"{}\"", hex::encode(&file.encrypted_hash));
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
|
.header(header::ETAG, etag)
|
||||||
|
.header(header::CONTENT_LENGTH, "0")
|
||||||
|
.header(header::CACHE_CONTROL, "private, no-store, max-age=0")
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
|
}
|
||||||
|
|
||||||
// Path traversal validation
|
// Path traversal validation
|
||||||
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -445,6 +513,7 @@ async fn serve_file(
|
|||||||
})?;
|
})?;
|
||||||
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
||||||
tracing::error!("Path traversal blocked: {:?}", canonical_path);
|
tracing::error!("Path traversal blocked: {:?}", canonical_path);
|
||||||
|
tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid);
|
||||||
return Err(CgcxError::Forbidden.into());
|
return Err(CgcxError::Forbidden.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +526,7 @@ async fn serve_file(
|
|||||||
.status(StatusCode::NOT_MODIFIED)
|
.status(StatusCode::NOT_MODIFIED)
|
||||||
.header(header::ETAG, etag.clone())
|
.header(header::ETAG, etag.clone())
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +540,7 @@ async fn serve_file(
|
|||||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes))
|
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -559,7 +628,7 @@ async fn serve_file(
|
|||||||
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
let body = Body::from_stream(body_stream);
|
let body = Body::from_stream(body_stream);
|
||||||
|
|
||||||
Ok(response.body(body).unwrap())
|
Ok(response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_decrypted_file(
|
async fn stream_decrypted_file(
|
||||||
@@ -587,6 +656,10 @@ async fn stream_decrypted_file(
|
|||||||
break; // EOF at message boundary
|
break; // EOF at message boundary
|
||||||
}
|
}
|
||||||
let msg_len = u32::from_le_bytes(len_buf) as usize;
|
let msg_len = u32::from_le_bytes(len_buf) as usize;
|
||||||
|
if msg_len > 50_000_000 {
|
||||||
|
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "message too large"))).await;
|
||||||
|
return Err(CgcxError::Crypto("message length exceeds sanity bound".into()));
|
||||||
|
}
|
||||||
let mut msg_buf = vec![0u8; msg_len];
|
let mut msg_buf = vec![0u8; msg_len];
|
||||||
file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
|
file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"ids": [], "updated_at": ""}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ids": [], "updated_at": ""}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture & Design Decisions
|
# 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.
|
This document explains the deeper design choices behind cg.cx - the trade-offs, threat models, and engineering rationale that shaped the system.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,11 +9,11 @@ This document explains the deeper design choices behind cg.cx — the trade-offs
|
|||||||
We chose **XChaCha20-Poly1305** (via libsodium's `crypto_secretstream_xchacha20poly1305`) as the bulk encryption primitive for several reasons:
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ AES is still present in the system — we use **AES-256-KW** (Key Wrap) to encry
|
|||||||
For a self-hosted, single-tenant service handling encrypted file metadata, **SQLite** is the correct default:
|
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.
|
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.
|
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.
|
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.
|
4. **Deployment footprint**: Ideal for running on a small VPS or even an embedded edge device without container orchestration.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
// "window.location.origin"
|
||||||
const API_BASE = "http://127.0.0.1:8090";
|
const API_BASE = "http://127.0.0.1:8090";
|
||||||
|
|
||||||
export async function fetchMetadata(cxid) {
|
export async function fetchMetadata(cxid) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
|
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) {
|
||||||
|
const err = new Error(await res.text());
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<main class="home">
|
<main class="home">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1 class="retro-heading">CG.CX</h1>
|
<h1 class="retro-heading">CG.CX</h1>
|
||||||
<p class="tagline">Secure content sharing</p>
|
<p class="cg-subtitle">-- cannibal girls --</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -86,11 +86,16 @@
|
|||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.tagline {
|
.cg-subtitle {
|
||||||
font-size: 1.2rem;
|
font-family: 'Press Start 2P', cursive;
|
||||||
color: var(--retro-green-light);
|
font-size: 0.9rem;
|
||||||
margin-top: 8px;
|
text-align: center;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--retro-green), var(--retro-green-light));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
width: min(400px, 100%);
|
width: min(400px, 100%);
|
||||||
|
|||||||
@@ -45,7 +45,18 @@
|
|||||||
phase = 'rendering'
|
phase = 'rendering'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
phase = 'error'
|
phase = 'error'
|
||||||
error = e.message || 'Failed to load content.'
|
const status = e.status || 0
|
||||||
|
if (status === 404) {
|
||||||
|
error = '[ Not Found ] This content does not exist or has been removed.'
|
||||||
|
} else if (status === 401) {
|
||||||
|
error = '[ Unauthorized ] This content requires a password.'
|
||||||
|
} else if (status === 429) {
|
||||||
|
error = '[ Rate Limited ] Too many requests. Please wait.'
|
||||||
|
} else if (status >= 500) {
|
||||||
|
error = '[ Server Error ] Something went wrong. Please try again later.'
|
||||||
|
} else {
|
||||||
|
error = '[ Error ] Failed to load content.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +107,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if phase === 'error'}
|
{:else if phase === 'error'}
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
<div class="error-box">
|
||||||
<p class="error">{error}</p>
|
<p class="error">{error}</p>
|
||||||
<button onclick={goHome}>Home</button>
|
<button onclick={goHome}>Home</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{:else if phase === 'rendering'}
|
{:else if phase === 'rendering'}
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<button class="small" onclick={goHome}><- Home</button>
|
<button class="small" onclick={goHome}><- Home</button>
|
||||||
@@ -155,7 +168,19 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.error { color: var(--retro-danger); }
|
.error { color: var(--retro-danger); font-size: 0.85rem; line-height: 1.5; }
|
||||||
|
.error-box {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
background: var(--retro-panel);
|
||||||
|
border: 3px solid var(--retro-danger);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 6px 6px 0px var(--retro-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ export default defineConfig({
|
|||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8090',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user