diff --git a/README.md b/README.md index 505a423..3b9aa59 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -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-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. | +| `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? @@ -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: -1. `config/default.toml` — committed defaults -2. `config/default.example.toml` — local overrides (gitignored) -3. `CGCX_*` environment variables — runtime overrides +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.: @@ -228,12 +228,12 @@ 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 +- `/` - 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 @@ -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/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/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: @@ -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: -- **🗑 Delete** — Sets content status to `deleted`. -- **⛔ Blacklist User** — Blacklists the uploader and bans them. -- **📝 Ignore** — Dismisses the report. +- **🗑 Delete** - Sets content status to `deleted`. +- **⛔ Blacklist User** - Blacklists the uploader and bans them. +- **📝 Ignore** - Dismisses the report. ### Moderation Modes @@ -436,7 +436,7 @@ cargo test -p cgcx-content-typing ## License -MIT License — see [LICENSE](LICENSE) for details. +MIT License - see [LICENSE](LICENSE) for details. --- diff --git a/config/default.example.toml b/config/default.example.toml index f937e85..79417d6 100644 --- a/config/default.example.toml +++ b/config/default.example.toml @@ -66,20 +66,20 @@ review_group_ids = [] 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 +chunk_size_bytes = 33_554_432 # = 32 MiB, 67_108_864 = 64 MiB # ---------------------------------------------------------------------------- # Upload Limits # ---------------------------------------------------------------------------- [upload_limits] # Maximum number of files per content entry. -max_batch_size = 10 +max_batch_size = 40 # 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 +max_total_batch_bytes = 2_147_483_648 # = 2 GiB, 5_368_709_120 = 5 GiB # ---------------------------------------------------------------------------- # HTTP Server diff --git a/crates/cgcx-bot/src/main.rs b/crates/cgcx-bot/src/main.rs index cb84c70..67bf652 100644 --- a/crates/cgcx-bot/src/main.rs +++ b/crates/cgcx-bot/src/main.rs @@ -150,6 +150,8 @@ async fn run_bot() { 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")); db.run_migrations().await.expect("Failed to run migrations"); @@ -201,6 +203,23 @@ async fn handle_message( msg: Message, storage: Arc>, 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, "An error occurred. Please try again.") + .parse_mode(ParseMode::Html) + .await; + } + Ok(()) +} + +#[inline(never)] +async fn handle_message_inner( + bot: Bot, + msg: Message, + storage: Arc>, + ctx: BotContext, ) -> HandlerResult { let user = match &msg.from { 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 { - bot.send_message(chat_id, "[ Banned ] You are not allowed to use this service.").await?; + bot.send_message(chat_id, "[ Banned ] You are not allowed to use this service.") + .parse_mode(ParseMode::Html) + .await?; dialogue.exit().await?; return Ok(()); } @@ -232,7 +253,9 @@ async fn handle_message( "/reload" => { if is_admin(&bot, msg.chat.id, user.id).await { ctx.moderation.load().await?; - bot.send_message(chat_id, "Moderation lists reloaded.").await?; + bot.send_message(chat_id, "Moderation lists reloaded.") + .parse_mode(ParseMode::Html) + .await?; } return Ok(()); } @@ -312,6 +335,23 @@ async fn handle_callback( q: CallbackQuery, storage: Arc>, 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, "An error occurred. Please try again.") + .parse_mode(ParseMode::Html) + .await; + } + Ok(()) +} + +#[inline(never)] +async fn handle_callback_inner( + bot: Bot, + q: CallbackQuery, + storage: Arc>, + ctx: BotContext, ) -> HandlerResult { // 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 @@ -323,8 +363,10 @@ async fn handle_callback( let message_id = q.message.as_ref().map(|m| m.id()); drop(q); + bot.answer_callback_query(&callback_id).await.ok(); + 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(()); } @@ -332,7 +374,7 @@ async fn handle_callback( let parts: Vec<&str> = data.split(':').collect(); if parts.len() < 3 || parts[0] != "v1" { - bot.answer_callback_query(&callback_id).await?; + bot.answer_callback_query(&callback_id).await.ok(); return Ok(()); } @@ -357,15 +399,15 @@ async fn handle_callback( "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?; + send_staging_message(&bot, chat_id, &[], UploadType::Media, ctx.config.upload_limits.max_batch_size).await?; } "upload_doc" => { 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" => { 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" => { dialogue.update(BotState::ViewingPrevious { page: 0 }).await?; @@ -373,7 +415,9 @@ async fn handle_callback( } "report" => { 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 content link or content ID to report.") + .parse_mode(ParseMode::Html) + .await?; } "main" => { send_main_menu(&bot, chat_id, &dialogue).await?; @@ -385,7 +429,7 @@ async fn handle_callback( let state = dialogue.get_or_default().await?; if let BotState::UploadStaging { items, .. } = state { 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 { let options = UploadOptions { allow_download: true, @@ -398,7 +442,9 @@ async fn handle_callback( } "cancel" => { if let Some(mid) = message_id { - bot.edit_message_text(chat_id, mid, "Upload cancelled.").await.ok(); + bot.edit_message_text(chat_id, mid, "Upload cancelled.") + .parse_mode(ParseMode::Html) + .await.ok(); } dialogue.update(BotState::MainMenu).await?; } @@ -425,7 +471,9 @@ async fn handle_callback( } } "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 password (max 32 chars) or /skip to skip.") + .parse_mode(ParseMode::Html) + .await?; } "confirm_final" => { let state = dialogue.get_or_default().await?; @@ -436,7 +484,7 @@ async fn handle_callback( } "back" => { 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(()) } @@ -481,30 +528,31 @@ async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> H ], vec![ 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![ 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, "Main Menu\n\nChoose from the menu below.\nAdministrators can be contacted here: t.me/harmfulmeowbot?start=submit") + .parse_mode(ParseMode::Html) .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 { +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 { 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) + format!("[ Staging {} ]\n\nSend me files to add them.\n\n0/{} items", type_label, max_batch_size) } 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 list: String = items.iter().map(|i| format!("• {}\n", i.file_name)).collect(); + format!("[ Staging {} ] {}/{}\n\n{}", type_label, items.len(), max_batch_size, list) }; 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) + .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await?; Ok(()) @@ -528,7 +577,9 @@ async fn handle_staging_message( 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?; + bot.send_message(msg.chat.id, "Maximum batch size reached.") + .parse_mode(ParseMode::Html) + .await?; return Ok(()); } @@ -598,7 +649,7 @@ async fn handle_staging_message( if let Some(item) = new_item { items.push(item); 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(()) @@ -611,22 +662,22 @@ async fn refresh_options_message( options: &UploadOptions, ) -> HandlerResult { let destroy_text = match options.max_views { - Some(n) => format!("Auto-destroy: {} views", n), - None => "Auto-destroy: Off".to_string(), + Some(n) => format!("Auto-destroy: {} views", n), + None => "Auto-destroy: Off".to_string(), }; let download_text = if options.allow_download { - "Allow download: Yes" + "Allow download: Yes" } else { - "Allow download: No" + "Allow download: No" }; let password_text = if options.password.is_some() { - "Password: Set" + "Password: Set" } else { - "Password: None" + "Password: None" }; let text = format!( - "[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.", + "[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.", destroy_text, download_text, password_text ); @@ -640,11 +691,12 @@ async fn refresh_options_message( ], vec![ 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) + .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await?; Ok(()) @@ -659,11 +711,15 @@ async fn finalize_upload( dialogue: &BotDialogue, ctx: &BotContext, ) -> HandlerResult { - let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]").await?; + let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]") + .parse_mode(ParseMode::Html) + .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?; + bot.edit_message_text(chat_id, status_msg.id, "[ Error ] Total batch size exceeds limit.") + .parse_mode(ParseMode::Html) + .await?; dialogue.update(BotState::MainMenu).await?; 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(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?; + bot.edit_message_text(chat_id, status_msg.id, "[ Error ] Insufficient storage space.") + .parse_mode(ParseMode::Html) + .await?; dialogue.update(BotState::MainMenu).await?; return Ok(()); } @@ -767,10 +825,14 @@ async fn finalize_upload( attrs.join(", ") }; - let result_text = format!( - "[ Upload Complete ]\n\nLink: {}\n\nFiles: {} | {}", + let mut result_text = format!( + "[ Upload Complete ]\n\nLink: {}\n\nFiles: {} | {}", 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\nDirect Access Link: {}", direct_link)); + } bot.edit_message_text(chat_id, status_msg.id, result_text) .parse_mode(ParseMode::Html) @@ -793,12 +855,14 @@ async fn show_previous_uploads( let total_pages = (total + 9) / 10; if items.is_empty() { - bot.send_message(chat_id, "You have no uploads.").await?; + bot.send_message(chat_id, "You have no uploads.") + .parse_mode(ParseMode::Html) + .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)); + let mut text = format!("[ My 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?; @@ -814,10 +878,28 @@ async fn show_previous_uploads( } let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") }; - text.push_str(&format!( - "- {} ({} files) [{}]\n {}?cxid={}\n\n", - content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str() - )); + if content.password_hash.is_some() { + text.push_str(&format!( + "• {} ({} files) [{}]\n {}?cxid={} (password protected)\n", + content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str() + )); + } else { + text.push_str(&format!( + "• {} ({} 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![]; @@ -828,10 +910,12 @@ async fn show_previous_uploads( if page + 1 < total_pages { buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1))); } - - let keyboard = InlineKeyboardMarkup::new(vec![buttons, vec![ + keyboard_rows.push(buttons); + keyboard_rows.push(vec![ InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"), - ]]); + ]); + + let keyboard = InlineKeyboardMarkup::new(keyboard_rows); bot.send_message(chat_id, text) .parse_mode(ParseMode::Html) @@ -860,7 +944,7 @@ async fn handle_report( for &group_id in &ctx.config.groups.review_group_ids { let report_text = format!( - "[ NEW REPORT ] #{}\n\nCXID: {}\nReporter: {}\nOwner: {}\nUploaded: {}\nFiles: {}", + "[ NEW REPORT ] #{}\n\nCXID: {}\nReporter: {}\nOwner: {}\nUploaded: {}\nFiles: {}", report_id, cxid, reporter_id, @@ -871,7 +955,7 @@ async fn handle_report( let keyboard = InlineKeyboardMarkup::new(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)), ], vec![ @@ -887,7 +971,9 @@ async fn handle_report( .ok(); } - bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.").await?; + bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.") + .parse_mode(ParseMode::Html) + .await?; dialogue.update(BotState::MainMenu).await?; Ok(()) } @@ -899,8 +985,37 @@ async fn handle_admin_callback( parts: &[&str], ctx: &BotContext, ) -> 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, "Content not found.") + .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, "Unauthorized.") + .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 {} deleted.", cxid)) + .parse_mode(ParseMode::Html).await?; + return Ok(()); + } + _ => {} + } + 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, "Unauthorized.") + .parse_mode(ParseMode::Html).await?; return Ok(()); } @@ -909,7 +1024,8 @@ async fn handle_admin_callback( let report = match report_repo.get(report_id).await? { Some(r) => r, None => { - bot.send_message(chat_id, "Report not found.").await?; + bot.send_message(chat_id, "Report not found.") + .parse_mode(ParseMode::Html).await?; return Ok(()); } }; @@ -918,27 +1034,28 @@ async fn handle_admin_callback( let content = match content_repo.get(&report.content_id).await? { Some(c) => c, None => { - bot.send_message(chat_id, "Content not found.").await?; + bot.send_message(chat_id, "Content not found.") + .parse_mode(ParseMode::Html).await?; return Ok(()); } }; match parts[2] { "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(); 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)) + 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(); + 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())) + bot.send_message(chat_id, format!("Deleted content {}", report.content_id.as_str())) .parse_mode(ParseMode::Html).await?; } "blk" => { @@ -946,12 +1063,12 @@ async fn handle_admin_callback( 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)) + 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)) + bot.send_message(chat_id, format!("Ignored report #{}", report_id)) .parse_mode(ParseMode::Html).await?; } _ => {} diff --git a/crates/cgcx-moderation/src/lib.rs b/crates/cgcx-moderation/src/lib.rs index 0146e8f..5e095cc 100644 --- a/crates/cgcx-moderation/src/lib.rs +++ b/crates/cgcx-moderation/src/lib.rs @@ -141,11 +141,20 @@ struct IdListFile { async fn load_id_set(path: &Path) -> Result> { if !path.exists() { + tokio::fs::write(path, "[]") + .await + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; return Ok(HashSet::new()); } let json = tokio::fs::read_to_string(path) .await .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::>(&json) { + return Ok(ids.into_iter().collect()); + } + let file: IdListFile = serde_json::from_str(&json) .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; Ok(file.ids.into_iter().collect()) diff --git a/crates/cgcx-server/src/main.rs b/crates/cgcx-server/src/main.rs index 641e76a..df68d34 100644 --- a/crates/cgcx-server/src/main.rs +++ b/crates/cgcx-server/src/main.rs @@ -20,7 +20,7 @@ use tower_http::{ catch_panic::CatchPanicLayer, compression::CompressionLayer, cors::{AllowOrigin, CorsLayer}, - services::{ServeDir, ServeFile}, + services::ServeDir, timeout::TimeoutLayer, trace::TraceLayer, }; @@ -71,6 +71,14 @@ struct VerifyPasswordRequest { struct FileQuery { #[serde(default)] download: bool, + #[serde(rename = "sc", default)] + sc: Option, +} + +#[derive(Deserialize, Default)] +struct ScQuery { + #[serde(rename = "sc", default)] + sc: Option, } struct ByteRange { @@ -88,16 +96,21 @@ impl From for AppError { impl IntoResponse for AppError { 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::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), 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::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()?); config.validate()?; + tokio::fs::create_dir_all("data").await.ok(); + let db = Arc::new(Database::open("data/db.sqlite")?); db.run_migrations().await?; @@ -171,8 +186,7 @@ async fn main() -> cgcx_core::Result<()> { config: Arc::new(password_governor_conf), }); - let static_service = ServeDir::new("frontend/dist") - .fallback(ServeFile::new("frontend/dist/index.html")); + let static_service = ServeDir::new("frontend/dist/assets"); let mut origins: Vec = vec![ 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}/file/{file_idx}", get(serve_file)) .merge(password_route) - .fallback_service(static_service) + .nest_service("/assets", static_service) + .fallback(fallback) .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( @@ -228,6 +242,7 @@ async fn main() -> cgcx_core::Result<()> { Duration::from_secs(30), )) .layer(CatchPanicLayer::new()) + .layer(cors) .with_state(state.clone()); // Spawn background sweeper task @@ -259,6 +274,18 @@ async fn main() -> cgcx_core::Result<()> { 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, next: Next) -> Response { let mut response = next.run(req).await; let headers = response.headers_mut(); @@ -281,21 +308,56 @@ async fn security_headers(req: axum::http::Request, next: Next) -> Respons } async fn health() -> impl IntoResponse { + tracing::info!("health"); axum::Json(HealthResponse { 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( State(state): State, Path(cxid): Path, + Query(query): Query, headers: HeaderMap, ) -> AppResult { + tracing::info!("get_metadata: cxid={}", cxid); 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 { + tracing::warn!("get_metadata returning NotFound for cxid={}", cxid); return Err(CgcxError::NotFound.into()); } @@ -304,23 +366,13 @@ async fn get_metadata( return Ok(Response::builder() .status(StatusCode::GONE) .body(Body::empty()) - .unwrap()); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); } } 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 { + if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) { + tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid); return Err(CgcxError::Unauthorized.into()); } } @@ -342,12 +394,12 @@ async fn get_metadata( 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)))?; + }).map_err(|_| CgcxError::BadRequest("json serialization".into()))?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) - .unwrap()) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?) } async fn verify_password( @@ -355,6 +407,7 @@ async fn verify_password( Path(cxid): Path, Json(req): Json, ) -> AppResult { + tracing::info!("verify_password: cxid={}", cxid); 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)?; @@ -363,7 +416,7 @@ async fn verify_password( return Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) - .unwrap()); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); }; use argon2::{Argon2, PasswordHash, PasswordVerifier}; @@ -373,12 +426,13 @@ async fn verify_password( .verify_password(req.password.as_bytes(), &parsed_hash) .is_ok(); if !valid { + tracing::warn!("verify_password returning Unauthorized for cxid={}", cxid); 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=/", + "cgcx_pw={}; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/", cookie_value ); @@ -386,7 +440,7 @@ async fn verify_password( .status(StatusCode::NO_CONTENT) .header(header::SET_COOKIE, cookie) .body(Body::empty()) - .unwrap()) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?) } async fn serve_file( @@ -395,11 +449,13 @@ async fn serve_file( Query(query): Query, headers: HeaderMap, ) -> AppResult { + tracing::info!("serve_file: cxid={} file_idx={}", cxid, file_idx); 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 { + tracing::warn!("serve_file returning NotFound for cxid={}", cxid); return Err(CgcxError::NotFound.into()); } @@ -408,28 +464,19 @@ async fn serve_file( return Ok(Response::builder() .status(StatusCode::GONE) .body(Body::empty()) - .unwrap()); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); } } 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 { + if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) { + tracing::warn!("serve_file returning Unauthorized for cxid={}", cxid); return Err(CgcxError::Unauthorized.into()); } } if query.download && !content.allow_download { + tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={}", cxid); return Err(CgcxError::Forbidden.into()); } @@ -437,6 +484,27 @@ async fn serve_file( 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)?; + // 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 let canonical_path = tokio::fs::canonicalize(&file.stored_path).await .map_err(|e| { @@ -445,6 +513,7 @@ async fn serve_file( })?; if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) { tracing::error!("Path traversal blocked: {:?}", canonical_path); + tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid); return Err(CgcxError::Forbidden.into()); } @@ -457,7 +526,7 @@ async fn serve_file( .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, etag.clone()) .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) .header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes)) .body(Body::empty()) - .unwrap()); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); } } } else { @@ -559,7 +628,7 @@ async fn serve_file( let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx); 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( @@ -587,6 +656,10 @@ async fn stream_decrypted_file( break; // EOF at message boundary } 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]; file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; diff --git a/data/blacklisted_ids.template.json b/data/blacklisted_ids.template.json deleted file mode 100644 index ed524a7..0000000 --- a/data/blacklisted_ids.template.json +++ /dev/null @@ -1 +0,0 @@ -{"ids": [], "updated_at": ""} diff --git a/data/whitelisted_ids.template.json b/data/whitelisted_ids.template.json deleted file mode 100644 index ed524a7..0000000 --- a/data/whitelisted_ids.template.json +++ /dev/null @@ -1 +0,0 @@ -{"ids": [], "updated_at": ""} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff9ca7f..9b8ca22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 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: 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. 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: 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. 4. **Deployment footprint**: Ideal for running on a small VPS or even an embedded edge device without container orchestration. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 8def8fb..bd1ef0f 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,10 +1,15 @@ +// "window.location.origin" 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()); + if (!res.ok) { + const err = new Error(await res.text()); + err.status = res.status; + throw err; + } return res.json(); } diff --git a/frontend/src/routes/Home.svelte b/frontend/src/routes/Home.svelte index 094cce4..b3f4281 100644 --- a/frontend/src/routes/Home.svelte +++ b/frontend/src/routes/Home.svelte @@ -45,7 +45,7 @@

CG.CX

-

Secure content sharing

+

-- cannibal girls --

@@ -86,11 +86,16 @@ .hero { text-align: center; } - .tagline { - font-size: 1.2rem; - color: var(--retro-green-light); - margin-top: 8px; + .cg-subtitle { + font-family: 'Press Start 2P', cursive; + font-size: 0.9rem; + text-align: center; 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 { width: min(400px, 100%); diff --git a/frontend/src/routes/ViewContent.svelte b/frontend/src/routes/ViewContent.svelte index 9da2e78..b827cce 100644 --- a/frontend/src/routes/ViewContent.svelte +++ b/frontend/src/routes/ViewContent.svelte @@ -45,7 +45,18 @@ phase = 'rendering' } catch (e) { 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,8 +107,10 @@
{:else if phase === 'error'}
-

{error}

- +
+

{error}

+ +
{:else if phase === 'rendering'}
@@ -155,7 +168,19 @@ flex-direction: column; 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 { display: flex; align-items: center; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9b0e9f6..6e65ecf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,4 +7,9 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8090', + }, + }, })