diff --git a/Cargo.lock b/Cargo.lock index d0b615a..ff27abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "teloxide", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", ] @@ -466,6 +467,7 @@ dependencies = [ "tower-http", "tower_governor", "tracing", + "tracing-appender", "tracing-subscriber", ] @@ -634,6 +636,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -705,6 +716,15 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1715,6 +1735,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1919,6 +1945,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2524,6 +2556,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -2732,6 +2770,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -2936,6 +3005,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/crates/cgcx-bot/Cargo.toml b/crates/cgcx-bot/Cargo.toml index f9b887e..6007dfb 100644 --- a/crates/cgcx-bot/Cargo.toml +++ b/crates/cgcx-bot/Cargo.toml @@ -20,6 +20,7 @@ teloxide = { version = "0.13", features = ["macros"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" diff --git a/crates/cgcx-bot/src/main.rs b/crates/cgcx-bot/src/main.rs index 06a0702..1254e29 100644 --- a/crates/cgcx-bot/src/main.rs +++ b/crates/cgcx-bot/src/main.rs @@ -4,35 +4,38 @@ use teloxide::{ net::Download, prelude::*, types::{ - InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, CallbackQuery, - ChatMemberStatus, UserId, + InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId, ParseMode, CallbackQuery, + ChatMemberStatus, UserId, ChatPermissions, }, + RequestError, utils::command::BotCommands, }; use cgcx_config::Config; use cgcx_core::{ContentId, ContentStatus, ReportStatus, UserRole}; use cgcx_crypto::MasterKey; -use cgcx_db::{Database, UserRepo, ContentRepo, ContentFileRepo, ReportRepo}; +use cgcx_db::{Database, UserRepo, ContentRepo, ContentFileRepo, ReportRepo, ForwardRepo, PunishmentRepo}; use cgcx_file_pipeline::FilePipeline; use cgcx_moderation::ModerationEngine; use cgcx_storage::Storage as CgcxStorage; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; +use tracing_subscriber::prelude::*; #[derive(Clone, Default, Serialize, Deserialize)] pub enum BotState { #[default] Start, TermsPending, - MainMenu, - UploadStaging { items: Vec, upload_type: UploadType }, + MainMenu { pending_forward_id: Option }, + UploadStaging { items: Vec, upload_type: UploadType, pending_forward_id: Option }, UploadOptions { items: Vec, options: UploadOptions }, - UploadFinalizing, + UploadFinalizing { pending_forward_id: Option }, Reporting, ViewingPrevious { page: usize }, + SubmitMode { forward_id: i64, code: String }, } -#[derive(Clone, Copy, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub enum UploadType { Media, Document, @@ -57,6 +60,7 @@ pub struct UploadOptions { pub max_views: Option, pub allow_download: bool, pub password: Option, + pub pending_forward_id: Option, } type HandlerResult = Result<(), Box>; @@ -122,6 +126,8 @@ struct BotContext { moderation: Arc, pipeline: Arc, sem: Arc, + bot_username: String, + bot_id: UserId, } fn main() { @@ -146,10 +152,70 @@ fn main() { } async fn run_bot() { - tracing_subscriber::fmt::init(); - let config = Arc::new(Config::load().expect("Failed to load config")); + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&config.logging.level)); + + let console_layer = tracing_subscriber::fmt::layer(); + + let _file_guard = if config.logging.file_enabled { + let log_path = std::path::Path::new(&config.logging.file_path); + let log_dir = log_path.parent() + .and_then(|p| p.to_str()) + .unwrap_or("data/logs"); + let log_prefix = log_path.file_name() + .and_then(|f| f.to_str()) + .unwrap_or("cgcx-bot.log"); + std::fs::create_dir_all(log_dir).ok(); + match tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(log_prefix) + .max_log_files(config.logging.max_files) + .build(log_dir) + { + Ok(file_appender) => { + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false); + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .with(file_layer) + .init(); + Some(guard) + } + Err(e) => { + tracing::warn!("Failed to create rolling file appender at {}: {}. Falling back to console only.", log_dir, e); + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .init(); + None + } + } + } else { + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .init(); + None + }; + + // Log panics so we can diagnose 500s that CatchPanicLayer swallows. + std::panic::set_hook(Box::new(|info| { + let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "unknown panic payload".to_string() + }; + let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default(); + tracing::error!("PANIC at {}: {}", location, msg); + })); + let db_path = std::path::PathBuf::from(&config.database_path); if let Some(parent) = db_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); @@ -180,17 +246,52 @@ async fn run_bot() { (1024 * 1024 * 1024 / config.storage.chunk_size_bytes.max(1)).max(1) )); - let ctx = BotContext { - db, storage, config: config.clone(), - master_key: Arc::new(master_key), moderation, pipeline, sem, - }; - let client = teloxide::net::default_reqwest_settings() .timeout(std::time::Duration::from_secs(60)) .build() .expect("reqwest client build failed"); let bot = Bot::with_client(&config.telegram.bot_token, client); - info!("Bot started"); + let me = bot.get_me().await.expect("Failed to get bot info"); + let bot_username = me.user.username.unwrap_or_default(); + let bot_id = me.user.id; + info!("Bot started as @{} (id={})", bot_username, bot_id.0); + + let ctx = BotContext { + db, storage, config: config.clone(), + master_key: Arc::new(master_key), moderation, pipeline, sem, + bot_username, bot_id, + }; + + let bot_clone = bot.clone(); + let db_clone = ctx.db.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let repo = PunishmentRepo::new(db_clone.conn()); + match repo.list_expired().await { + Ok(expired) => { + for p in expired { + let chat_id = ChatId(p.chat_id); + let target = UserId(p.target_user_id as u64); + match p.action_type.as_str() { + "ban" => { + let _ = bot_clone.unban_chat_member(chat_id, target).await; + } + "mute" => { + let _ = bot_clone.restrict_chat_member(chat_id, target, ChatPermissions::SEND_MESSAGES | ChatPermissions::SEND_MEDIA_MESSAGES | ChatPermissions::SEND_OTHER_MESSAGES | ChatPermissions::ADD_WEB_PAGE_PREVIEWS).await; + } + _ => {} + } + let _ = repo.revoke(p.id, 0).await; // 0 = system + } + } + Err(e) => { + tracing::warn!("Failed to check expired punishments: {}", e); + } + } + } + }); let handler = dptree::entry() .branch(Update::filter_message().endpoint(handle_message)) @@ -228,12 +329,39 @@ async fn handle_message_inner( storage: Arc>, ctx: BotContext, ) -> HandlerResult { + // Batch 13: Automatic service message cleanup in groups/channels. + // Some messages cannot be deleted by bots (e.g., channel creation); failures are silently ignored. + if !msg.chat.is_private() { + let is_service = msg.new_chat_members().is_some() + || msg.left_chat_member().is_some() + || msg.new_chat_title().is_some() + || msg.new_chat_photo().is_some() + || msg.delete_chat_photo().is_some() + || msg.group_chat_created().is_some() + || msg.super_group_chat_created().is_some() + || msg.channel_chat_created().is_some() + || msg.migrate_to_chat_id().is_some() + || msg.migrate_from_chat_id().is_some() + || msg.pinned_message().is_some() + || msg.video_chat_scheduled().is_some() + || msg.video_chat_started().is_some() + || msg.video_chat_ended().is_some() + || msg.video_chat_participants_invited().is_some() + || msg.message_auto_delete_timer_changed().is_some() + || msg.proximity_alert_triggered().is_some(); + if is_service { + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + } + let user = match &msg.from { Some(u) => u.clone(), None => return Ok(()), }; let chat_id = msg.chat.id; let user_id = user.id.0 as i64; + tracing::info!("msg chat={} user={} text={:?}", chat_id, user_id, msg.text()); let dialogue = BotDialogue { chat_id, storage }; let user_repo = UserRepo::new(ctx.db.conn()); @@ -258,6 +386,7 @@ async fn handle_message_inner( let cmd = text.split_whitespace().next().unwrap_or(""); match cmd { "/reload" => { + tracing::info!("admin command /reload chat={} user={}", chat_id, user_id); if is_admin(&bot, msg.chat.id, user.id).await { ctx.moderation.load().await?; bot.send_message(chat_id, "Moderation lists reloaded.") @@ -267,17 +396,334 @@ async fn handle_message_inner( return Ok(()); } "/blacklist_uid" => { + tracing::info!("admin command /blacklist_uid chat={} user={}", chat_id, user_id); if is_admin(&bot, msg.chat.id, user.id).await { handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?; } return Ok(()); } "/whitelist_uid" => { + tracing::info!("admin command /whitelist_uid chat={} user={}", chat_id, user_id); if is_admin(&bot, msg.chat.id, user.id).await { handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?; } return Ok(()); } + "/help" => { + tracing::info!("admin command /help chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let help_text = r#"Admin Commands + +/reload — Reload moderation lists. +/blacklist_uid — Blacklist a user ID. +/whitelist_uid — Remove a user from blacklist. +/help — Show this message. +/get_id — Get current chat ID. +/get_id <@username> — Search administrators by username. +/get_id — Search members in this chat by display name. +/create_submit_forward [msg] — Create a submission forward. +/show_c_forward [page] — List forward links. +/add_blacklist — Blacklist a user in all active forwards. +/rm_blacklist — Remove a user from blacklist in all active forwards. +/sban @user [reason] — Ban for duration +/smute @user [reason] — Mute for duration +/mute @user [reason] — Mute indefinitely +/pban @user [reason] — Permanent ban +/kick @user [reason] — Kick from group +/rmute @user — Revoke mute +/rban @user — Revoke ban"#; + bot.send_message(chat_id, help_text).parse_mode(ParseMode::Html).await?; + } + return Ok(()); + } + "/get_id" => { + tracing::info!("admin command /get_id chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().collect(); + if args.len() == 1 { + bot.send_message(chat_id, format!("Chat ID: {}", chat_id.0)) + .parse_mode(ParseMode::Html).await?; + } else { + let target = args[1]; + handle_get_id_search(&bot, chat_id, target, &ctx).await?; + } + } + return Ok(()); + } + "/create_submit_forward" => { + tracing::info!("admin command /create_submit_forward chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().collect(); + if args.len() < 3 { + bot.send_message(chat_id, "Usage: /create_submit_forward [forward_message]").await?; + } else { + let dest = args[1].parse::().unwrap_or(0); + let review = args[2].parse::().unwrap_or(0); + let fwd_msg = args[3..].join(" "); + let bot_id = ctx.bot_id; + let bot_admin_dest = is_admin(&bot, ChatId(dest), bot_id).await; + let bot_admin_review = is_admin(&bot, ChatId(review), bot_id).await; + if !bot_admin_dest || !bot_admin_review { + bot.send_message(chat_id, "Bot must be admin in both destination and review group.").await?; + } else { + let code = generate_forward_code(); + let forward_repo = ForwardRepo::new(ctx.db.conn()); + forward_repo.insert(user_id, chat_id.0, dest, review, &fwd_msg, &code, "b").await?; + let link = format!("https://t.me/{}?start=submitfwdid{}", ctx.bot_username, code); + bot.send_message(chat_id, format!("Forward link created:\n{}", link)).parse_mode(ParseMode::Html).await?; + } + } + } + return Ok(()); + } + "/show_c_forward" => { + tracing::info!("admin command /show_c_forward chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let page = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()).unwrap_or(0); + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let forwards = forward_repo.list_by_source_chat(chat_id.0, 5, page * 5).await?; + if forwards.is_empty() { + bot.send_message(chat_id, "No forward links for this chat.").await?; + } else { + let mut keyboard_rows = vec![]; + let mut text = format!("[ Forward Links ] Page {}\n\n", page + 1); + for fwd in &forwards { + let status = if fwd.revoked_at.is_some() { "Revoked" } else { "Active" }; + text.push_str(&format!( + "Code: {}\nDest: {} | Review: {}\nStatus: {}\n\n", + fwd.code, fwd.destination_chat_id, fwd.review_group_id, status + )); + if fwd.revoked_at.is_none() { + keyboard_rows.push(vec![ + InlineKeyboardButton::callback( + format!("[ Revoke {} ]", &fwd.code[..8.min(fwd.code.len())]), + format!("v1:fwd:revoke:{}", fwd.id) + ) + ]); + } + } + let mut nav = vec![]; + if page > 0 { + nav.push(InlineKeyboardButton::callback("<<", format!("v1:fwd:page:{}", page - 1))); + } + nav.push(InlineKeyboardButton::callback(format!("Page {}/?", page + 1), "noop")); + nav.push(InlineKeyboardButton::callback(">>", format!("v1:fwd:page:{}", page + 1))); + keyboard_rows.push(nav); + bot.send_message(chat_id, text) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_rows)) + .await?; + } + } + return Ok(()); + } + "/add_blacklist" => { + tracing::info!("admin command /add_blacklist chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let target_id = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); + if let Some(target) = target_id { + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let forwards = forward_repo.list_by_source_chat(chat_id.0, 1000, 0).await?; + let mut count = 0; + for fwd in &forwards { + if fwd.revoked_at.is_none() { + forward_repo.add_to_list(fwd.id, target, "blacklist").await.ok(); + count += 1; + } + } + bot.send_message(chat_id, format!("Blacklisted {} in {} active forwards.", target, count)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "Usage: /add_blacklist ").await?; + } + } + return Ok(()); + } + "/rm_blacklist" => { + tracing::info!("admin command /rm_blacklist chat={} user={}", chat_id, user_id); + if is_admin(&bot, msg.chat.id, user.id).await { + let target_id = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); + if let Some(target) = target_id { + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let forwards = forward_repo.list_by_source_chat(chat_id.0, 1000, 0).await?; + let mut count = 0; + for fwd in &forwards { + if fwd.revoked_at.is_none() { + forward_repo.remove_from_list(fwd.id, target, "blacklist").await.ok(); + count += 1; + } + } + bot.send_message(chat_id, format!("Removed {} from blacklist in {} active forwards.", target, count)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "Usage: /rm_blacklist ").await?; + } + } + return Ok(()); + } + "/sban" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.len() < 3 { + bot.send_message(chat_id, "Usage: /sban @user [reason]").await?; + return Ok(()); + } + let target_arg = args[0]; + let reason = if args.len() > 3 { Some(args[3..].join(" ")) } else { None }; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + let dur_parts = &args[1..3]; + match parse_duration(dur_parts) { + Ok(Some(duration_seconds)) => { + let until = chrono::Utc::now() + chrono::Duration::seconds(duration_seconds); + bot.ban_chat_member(chat_id, UserId(target_id)).until_date(until).await?; + let repo = PunishmentRepo::new(ctx.db.conn()); + repo.insert(chat_id.0, target_id as i64, "ban", Some(duration_seconds), reason.as_deref(), user_id).await?; + bot.send_message(chat_id, format!("Banned {} for {} seconds.", target_id, duration_seconds)).parse_mode(ParseMode::Html).await?; + } + Ok(None) => { /* shouldn't happen with 2 args */ } + Err(e) => { bot.send_message(chat_id, e).await?; } + } + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/smute" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.len() < 3 { + bot.send_message(chat_id, "Usage: /smute @user [reason]").await?; + return Ok(()); + } + let target_arg = args[0]; + let reason = if args.len() > 3 { Some(args[3..].join(" ")) } else { None }; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + let dur_parts = &args[1..3]; + match parse_duration(dur_parts) { + Ok(Some(duration_seconds)) => { + let until = chrono::Utc::now() + chrono::Duration::seconds(duration_seconds); + bot.restrict_chat_member(chat_id, UserId(target_id), ChatPermissions::empty()).until_date(until).await?; + let repo = PunishmentRepo::new(ctx.db.conn()); + repo.insert(chat_id.0, target_id as i64, "mute", Some(duration_seconds), reason.as_deref(), user_id).await?; + bot.send_message(chat_id, format!("Muted {} for {} seconds.", target_id, duration_seconds)).parse_mode(ParseMode::Html).await?; + } + Ok(None) => {} + Err(e) => { bot.send_message(chat_id, e).await?; } + } + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/mute" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.is_empty() { + bot.send_message(chat_id, "Usage: /mute @user [reason]").await?; + return Ok(()); + } + let target_arg = args[0]; + let reason = if args.len() > 1 { Some(args[1..].join(" ")) } else { None }; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + bot.restrict_chat_member(chat_id, UserId(target_id), ChatPermissions::empty()).await?; + let repo = PunishmentRepo::new(ctx.db.conn()); + repo.insert(chat_id.0, target_id as i64, "mute", None, reason.as_deref(), user_id).await?; + bot.send_message(chat_id, format!("Muted {} indefinitely.", target_id)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/pban" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.is_empty() { + bot.send_message(chat_id, "Usage: /pban @user [reason]").await?; + return Ok(()); + } + let target_arg = args[0]; + let reason = if args.len() > 1 { Some(args[1..].join(" ")) } else { None }; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + bot.ban_chat_member(chat_id, UserId(target_id)).await?; + let repo = PunishmentRepo::new(ctx.db.conn()); + repo.insert(chat_id.0, target_id as i64, "ban", None, reason.as_deref(), user_id).await?; + bot.send_message(chat_id, format!("Banned {} permanently.", target_id)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/kick" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.is_empty() { + bot.send_message(chat_id, "Usage: /kick @user [reason]").await?; + return Ok(()); + } + let target_arg = args[0]; + let reason = if args.len() > 1 { Some(args[1..].join(" ")) } else { None }; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + bot.ban_chat_member(chat_id, UserId(target_id)).await?; + bot.unban_chat_member(chat_id, UserId(target_id)).await?; + let repo = PunishmentRepo::new(ctx.db.conn()); + repo.insert(chat_id.0, target_id as i64, "kick", None, reason.as_deref(), user_id).await?; + bot.send_message(chat_id, format!("Kicked {}.", target_id)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/rmute" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.is_empty() { + bot.send_message(chat_id, "Usage: /rmute @user").await?; + return Ok(()); + } + let target_arg = args[0]; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + let repo = PunishmentRepo::new(ctx.db.conn()); + let active = repo.get_active_for_chat_target(chat_id.0, target_id as i64, "mute").await?; + if let Some(p) = active.into_iter().next() { + bot.restrict_chat_member(chat_id, UserId(target_id), ChatPermissions::SEND_MESSAGES | ChatPermissions::SEND_MEDIA_MESSAGES | ChatPermissions::SEND_OTHER_MESSAGES | ChatPermissions::ADD_WEB_PAGE_PREVIEWS).await?; + repo.revoke(p.id, user_id).await?; + bot.send_message(chat_id, format!("Unmuted {}.", target_id)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "No active mute found for this user.").await?; + } + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } + "/rban" => { + if is_admin(&bot, msg.chat.id, user.id).await { + let args: Vec<&str> = text.split_whitespace().skip(1).collect(); + if args.is_empty() { + bot.send_message(chat_id, "Usage: /rban @user").await?; + return Ok(()); + } + let target_arg = args[0]; + if let Some(target_id) = resolve_target_user_id(&bot, chat_id, target_arg).await? { + let repo = PunishmentRepo::new(ctx.db.conn()); + let active = repo.get_active_for_chat_target(chat_id.0, target_id as i64, "ban").await?; + if let Some(p) = active.into_iter().next() { + bot.unban_chat_member(chat_id, UserId(target_id)).await?; + repo.revoke(p.id, user_id).await?; + bot.send_message(chat_id, format!("Unbanned {}.", target_id)).parse_mode(ParseMode::Html).await?; + } else { + bot.send_message(chat_id, "No active ban found for this user.").await?; + } + } else { + bot.send_message(chat_id, "Could not resolve target user.").await?; + } + } + return Ok(()); + } _ => {} } } @@ -288,15 +734,46 @@ async fn handle_message_inner( let cmd = text.split_whitespace().next().unwrap_or("").split('@').next().unwrap_or(""); match cmd { "/start" => { + tracing::info!("dm command /start chat={} user={}", chat_id, user_id); + let payload = text.strip_prefix("/start ").or_else(|| text.strip_prefix("/start")); + if let Some(p) = payload { + if !p.is_empty() && p.starts_with("submitfwdid") { + let code = &p["submitfwdid".len()..]; + let forward_repo = ForwardRepo::new(ctx.db.conn()); + if let Some(def) = forward_repo.get_by_code(code).await? { + if def.revoked_at.is_some() { + bot.send_message(chat_id, "This submission link has been revoked.").await?; + } else { + if forward_repo.is_allowed(def.id, user_id).await? { + dialogue.update(BotState::SubmitMode { forward_id: def.id, code: code.to_string() }).await?; + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("[ Continue ]", "v1:submit:continue"), + InlineKeyboardButton::callback("[ Exit ]", "v1:submit:exit"), + ]]); + bot.send_message(chat_id, "[ Submission Mode ]\n\nYou are about to submit content to a forward.\n\nContinue to upload content for submission, or exit to return to the main menu.") + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } else { + bot.send_message(chat_id, "You are not allowed to use this submission link.").await?; + } + } + } else { + bot.send_message(chat_id, "Invalid submission link.").await?; + } + return Ok(()); + } + } if db_user.accepted_terms_at.is_some() { - return send_main_menu(&bot, chat_id, &dialogue).await; + return send_main_menu(&bot, chat_id, &dialogue, None).await; } else { return send_terms(&bot, chat_id, &dialogue).await; } } "/cancel" => { - dialogue.update(BotState::MainMenu).await?; - return send_main_menu(&bot, chat_id, &dialogue).await; + tracing::info!("dm command /cancel chat={} user={}", chat_id, user_id); + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; + return send_main_menu(&bot, chat_id, &dialogue, None).await; } _ => {} } @@ -309,8 +786,8 @@ async fn handle_message_inner( send_terms(&bot, chat_id, &dialogue).await?; } } - BotState::UploadStaging { items, upload_type } => { - handle_staging_message(&bot, msg, &dialogue, &ctx, items, upload_type).await?; + BotState::UploadStaging { items, upload_type, pending_forward_id } => { + handle_staging_message(&bot, msg, &dialogue, &ctx, items, upload_type, pending_forward_id).await?; } BotState::UploadOptions { items, options } => { if let Some(text) = msg.text() { @@ -379,6 +856,8 @@ async fn handle_callback_inner( let dialogue = BotDialogue { chat_id, storage }; + tracing::info!("callback user={} data={}", user_id, data); + let parts: Vec<&str> = data.split(':').collect(); if parts.len() < 3 || parts[0] != "v1" { bot.answer_callback_query(&callback_id).await.ok(); @@ -386,14 +865,16 @@ async fn handle_callback_inner( } match parts[1] { - "terms" => match parts[2] { + "terms" => { + tracing::info!("callback terms action={}", parts[2]); + match parts[2] { "accept" => { let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_accepted_terms(user_id).await?; if let Some(mid) = message_id { bot.delete_message(chat_id, mid).await.ok(); } - send_main_menu(&bot, chat_id, &dialogue).await?; + send_main_menu(&bot, chat_id, &dialogue, None).await?; } "reject" => { if let Some(mid) = message_id { @@ -402,18 +883,27 @@ async fn handle_callback_inner( dialogue.reset().await?; } _ => {} - }, - "menu" => match parts[2] { + } + } + "menu" => { + tracing::info!("callback menu action={}", parts[2]); + let state = dialogue.get_or_default().await?; + let pending = if let BotState::MainMenu { pending_forward_id } = state { + pending_forward_id + } else { + None + }; + match parts[2] { "upload_media" => { - dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media, pending_forward_id: pending }).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?; + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document, pending_forward_id: pending }).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?; + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text, pending_forward_id: pending }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Text, ctx.config.upload_limits.max_batch_size).await?; } "prev_uploads" => { @@ -427,19 +917,23 @@ async fn handle_callback_inner( .await?; } "main" => { - send_main_menu(&bot, chat_id, &dialogue).await?; + send_main_menu(&bot, chat_id, &dialogue, pending).await?; } _ => {} - }, - "stage" => match parts[2] { + } + } + "stage" => { + tracing::info!("callback stage action={}", parts[2]); + match parts[2] { "confirm" => { let state = dialogue.get_or_default().await?; - if let BotState::UploadStaging { items, .. } = state { + if let BotState::UploadStaging { items, pending_forward_id, .. } = state { if items.is_empty() { bot.answer_callback_query(&callback_id).text("No items to upload.").await.ok(); } else { let options = UploadOptions { allow_download: true, + pending_forward_id, ..Default::default() }; dialogue.update(BotState::UploadOptions { items, options: options.clone() }).await?; @@ -453,11 +947,14 @@ async fn handle_callback_inner( .parse_mode(ParseMode::Html) .await.ok(); } - dialogue.update(BotState::MainMenu).await?; + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; } _ => {} - }, - "opt" => match parts[2] { + } + } + "opt" => { + tracing::info!("callback opt action={}", parts[2]); + match parts[2] { "toggle_destroy" => { let state = dialogue.get_or_default().await?; if let BotState::UploadOptions { items, options } = state { @@ -485,17 +982,25 @@ async fn handle_callback_inner( "confirm_final" => { let state = dialogue.get_or_default().await?; if let BotState::UploadOptions { items, options } = state { - dialogue.update(BotState::UploadFinalizing).await?; + dialogue.update(BotState::UploadFinalizing { pending_forward_id: options.pending_forward_id }).await?; finalize_upload(&bot, chat_id, user_id, items, options, &dialogue, &ctx).await?; } } "back" => { - dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; + let state = dialogue.get_or_default().await?; + let pending = if let BotState::UploadOptions { options, .. } = state { + options.pending_forward_id + } else { + None + }; + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media, pending_forward_id: pending }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Media, ctx.config.upload_limits.max_batch_size).await?; } _ => {} - }, + } + } "prev" => { + tracing::info!("callback prev action={}", parts[2]); if parts[2] == "page" { if let Ok(page) = parts[3].parse::() { dialogue.update(BotState::ViewingPrevious { page }).await?; @@ -503,11 +1008,102 @@ async fn handle_callback_inner( } } } + "submit" => { + tracing::info!("callback submit action={}", parts[2]); + let state = dialogue.get_or_default().await?; + if let BotState::SubmitMode { forward_id, .. } = state { + match parts[2] { + "continue" => { + dialogue.update(BotState::MainMenu { pending_forward_id: Some(forward_id) }).await?; + send_main_menu(&bot, chat_id, &dialogue, Some(forward_id)).await?; + } + "exit" => { + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; + send_main_menu(&bot, chat_id, &dialogue, None).await?; + } + _ => {} + } + } + } "admin" => { + tracing::info!("callback admin action={}", parts[2]); if parts.len() >= 4 { handle_admin_callback(&bot, chat_id, user_id, &parts, &ctx).await?; } } + "fwd" => { + if parts.len() >= 4 { + match parts[2] { + "revoke" => { + let forward_id = parts[3].parse::().unwrap_or(0); + let forward_repo = ForwardRepo::new(ctx.db.conn()); + if let Some(fwd) = forward_repo.get_definition(forward_id).await? { + if fwd.source_chat_id != chat_id.0 { + bot.send_message(chat_id, "This forward does not belong to this chat.").await?; + return Ok(()); + } + if fwd.revoked_at.is_some() { + bot.send_message(chat_id, "This forward has already been revoked.").await?; + return Ok(()); + } + let is_creator = fwd.creator_user_id == user_id; + let is_admin_user = is_admin_in_chat(&bot, chat_id, UserId(user_id as u64)).await; + if !is_creator && !is_admin_user { + bot.send_message(chat_id, "Unauthorized.").await?; + return Ok(()); + } + forward_repo.revoke(forward_id).await?; + bot.send_message(chat_id, format!("Forward {} revoked.", fwd.code)).parse_mode(ParseMode::Html).await?; + } + } + "page" => { + if !is_admin_in_chat(&bot, chat_id, UserId(user_id as u64)).await { + bot.send_message(chat_id, "Unauthorized.").await?; + return Ok(()); + } + if let Ok(page) = parts[3].parse::() { + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let forwards = forward_repo.list_by_source_chat(chat_id.0, 5, page * 5).await?; + if forwards.is_empty() { + bot.send_message(chat_id, "No forward links for this chat.").await?; + } else { + let mut keyboard_rows = vec![]; + let mut text = format!("[ Forward Links ] Page {}\n\n", page + 1); + for fwd in &forwards { + let status = if fwd.revoked_at.is_some() { "Revoked" } else { "Active" }; + text.push_str(&format!( + "Code: {}\nDest: {} | Review: {}\nStatus: {}\n\n", + fwd.code, fwd.destination_chat_id, fwd.review_group_id, status + )); + if fwd.revoked_at.is_none() { + keyboard_rows.push(vec![ + InlineKeyboardButton::callback( + format!("[ Revoke {} ]", &fwd.code[..8.min(fwd.code.len())]), + format!("v1:fwd:revoke:{}", fwd.id) + ) + ]); + } + } + let mut nav = vec![]; + if page > 0 { + nav.push(InlineKeyboardButton::callback("<<", format!("v1:fwd:page:{}", page - 1))); + } + nav.push(InlineKeyboardButton::callback(format!("Page {}/?", page + 1), "noop")); + nav.push(InlineKeyboardButton::callback(">>", format!("v1:fwd:page:{}", page + 1))); + keyboard_rows.push(nav); + bot.send_message(chat_id, text) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_rows)) + .await?; + } + } + } + _ => { + handle_forward_callback(&bot, chat_id, user_id, &parts, &ctx).await?; + } + } + } + } _ => {} } @@ -527,7 +1123,7 @@ async fn send_terms(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> Handl Ok(()) } -async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult { +async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue, pending_forward_id: Option) -> HandlerResult { let keyboard = InlineKeyboardMarkup::new(vec![ vec![ InlineKeyboardButton::callback("[ Upload Media ]", "v1:menu:upload_media"), @@ -545,7 +1141,7 @@ async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> H .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await?; - dialogue.update(BotState::MainMenu).await?; + dialogue.update(BotState::MainMenu { pending_forward_id }).await?; Ok(()) } @@ -582,7 +1178,9 @@ async fn handle_staging_message( ctx: &BotContext, mut items: Vec, upload_type: UploadType, + pending_forward_id: Option, ) -> HandlerResult { + tracing::info!("handle_staging_message upload_type={:?} items={}", upload_type, items.len()); if items.len() >= ctx.config.upload_limits.max_batch_size { bot.send_message(msg.chat.id, "Maximum batch size reached.") .parse_mode(ParseMode::Html) @@ -647,6 +1245,30 @@ async fn handle_staging_message( caption: Some(text.to_string()), }); } + } else if let Some(doc) = msg.document() { + let file_name = doc.file_name.clone().unwrap_or_else(|| "text.txt".to_string()); + let mime_type = doc.mime_type.as_ref().map(|m| m.to_string()).unwrap_or_else(|| "text/plain".to_string()); + if file_name.ends_with(".txt") || mime_type.starts_with("text/") { + tracing::info!("staging text document: {}", file_name); + match bot.get_file(&doc.file.id).await { + Ok(file) => { + let mut data = Vec::new(); + if bot.download_file(&file.path, &mut data).await.is_ok() { + let text_content = String::from_utf8_lossy(&data).to_string(); + new_item = Some(StagedItem { + file_id: doc.file.id.clone(), + file_name, + mime_type, + size: doc.file.size as u64, + caption: Some(text_content), + }); + } + } + Err(e) => { + warn!("Failed to get text document file: {}", e); + } + } + } } } } @@ -655,7 +1277,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?; + dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type, pending_forward_id }).await?; send_staging_message(bot, chat_id, &items, upload_type, ctx.config.upload_limits.max_batch_size).await?; } @@ -727,7 +1349,7 @@ async fn finalize_upload( 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?; + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; return Ok(()); } @@ -738,7 +1360,7 @@ async fn finalize_upload( bot.edit_message_text(chat_id, status_msg.id, "[ Error ] Insufficient storage space.") .parse_mode(ParseMode::Html) .await?; - dialogue.update(BotState::MainMenu).await?; + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; return Ok(()); } } @@ -751,6 +1373,7 @@ async fn finalize_upload( content_id = ContentId::generate(); attempts += 1; } + tracing::info!("finalize_upload start cxid={} user={}", content_id.as_str(), user_id); let password_hash = options.password.as_ref().map(|p| { use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; @@ -814,6 +1437,30 @@ async fn finalize_upload( ctx.pipeline.activate_content(&content_id).await?; + if let Some(fid) = options.pending_forward_id { + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let submission_id = forward_repo.insert_submission(fid, user_id, content_id.as_str()).await?; + if let Some(forward_def) = forward_repo.get_definition(fid).await? { + let review_text = format!( + "[ NEW SUBMISSION ] #{}\n\nCXID: {}\nUser: {}\nForward: {}", + submission_id, + content_id.as_str(), + user_id, + forward_def.id + ); + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("[ Approve ]", format!("v1:fwd:approve:{}", submission_id)), + InlineKeyboardButton::callback("[ Ignore ]", format!("v1:fwd:ignore:{}", submission_id)), + InlineKeyboardButton::callback("[ Blacklist User ]", format!("v1:fwd:blk:{}", submission_id)), + ]]); + let sent = bot.send_message(ChatId(forward_def.review_group_id), review_text) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + forward_repo.set_review_message_id(submission_id, sent.id.0).await?; + } + } + let base_url = &ctx.config.server.base_url; let link = format!("{}/?cxid={}", base_url, content_id.as_str()); @@ -846,7 +1493,8 @@ async fn finalize_upload( .parse_mode(ParseMode::Html) .await?; - dialogue.update(BotState::MainMenu).await?; + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; + tracing::info!("finalize_upload complete cxid={} user={}", content_id.as_str(), user_id); Ok(()) } @@ -942,6 +1590,7 @@ async fn handle_report( ctx: &BotContext, ) -> HandlerResult { let cxid = extract_cxid(text).ok_or("Invalid content ID or link")?; + tracing::info!("handle_report reporter={} cxid={}", reporter_id, cxid); let content_id = ContentId::try_from(cxid.as_str())?; let repo = ContentRepo::new(ctx.db.conn()); @@ -982,7 +1631,7 @@ async fn handle_report( bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.") .parse_mode(ParseMode::Html) .await?; - dialogue.update(BotState::MainMenu).await?; + dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?; Ok(()) } @@ -993,6 +1642,7 @@ async fn handle_admin_callback( parts: &[&str], ctx: &BotContext, ) -> HandlerResult { + tracing::info!("handle_admin_callback user={} action={}", user_id, parts[2]); match parts[2] { "delcontent" => { let cxid = parts[3]; @@ -1028,6 +1678,7 @@ async fn handle_admin_callback( } let report_id = parts[3].parse::().unwrap_or(0); + tracing::info!("handle_admin_callback user={} report_id={} action={}", user_id, report_id, parts[2]); let report_repo = ReportRepo::new(ctx.db.conn()); let report = match report_repo.get(report_id).await? { Some(r) => r, @@ -1085,6 +1736,108 @@ async fn handle_admin_callback( Ok(()) } +async fn handle_forward_callback( + bot: &Bot, + chat_id: ChatId, + user_id: i64, + parts: &[&str], + ctx: &BotContext, +) -> HandlerResult { + let action = parts[2]; + let submission_id = parts[3].parse::().unwrap_or(0); + if submission_id == 0 { return Ok(()); } + + let forward_repo = ForwardRepo::new(ctx.db.conn()); + let submission = match forward_repo.get_submission(submission_id).await? { + Some(s) => s, + None => { + bot.send_message(chat_id, "Submission not found.").await?; + return Ok(()); + } + }; + + if submission.status != "pending" { + bot.send_message(chat_id, "This submission has already been resolved.").await.ok(); + return Ok(()); + } + + let forward_def = match forward_repo.get_definition(submission.forward_id).await? { + Some(d) => d, + None => return Ok(()), + }; + + // Permission check: user must be admin in the review group + if !is_admin_in_chat(bot, ChatId(forward_def.review_group_id), UserId(user_id as u64)).await { + bot.send_message(chat_id, "Unauthorized.").await?; + return Ok(()); + } + + match action { + "approve" => { + // 1. Generate password + let password = generate_direct_password(); + // 2. Hash password + use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; + use rand::rngs::OsRng; + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()).unwrap_or_default(); + // 3. Update content password_hash + let content_repo = ContentRepo::new(ctx.db.conn()); + content_repo.update_password_hash(&submission.content_id, Some(&password_hash)).await?; + // 4. Forward content to destination + let link = format!("{}/?cxid={}&sc={}", ctx.config.server.base_url, submission.content_id.as_str(), password); + let posted_msg = bot.send_message( + ChatId(forward_def.destination_chat_id), + format!("{}\n\nDirect link: {}", forward_def.forward_message, link) + ).parse_mode(ParseMode::Html).await?; + + // 5. DM user + bot.send_message( + ChatId(submission.user_id), + format!("Your submission was approved.\n\nPosted: {}\nDirect access: {}", + format!("https://t.me/c/{}/{}", forward_def.destination_chat_id, posted_msg.id), + link) + ).parse_mode(ParseMode::Html).await.ok(); + + // 6. Update review message + if let Some(mid) = submission.review_message_id { + bot.edit_message_text(chat_id, MessageId(mid), format!("[ APPROVED ] #{}\nApproved by {}", submission_id, user_id)) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(Vec::>::new())) + .await.ok(); + } + + // 7. Update status + forward_repo.update_status(submission_id, "approved").await?; + } + "ignore" => { + bot.send_message(ChatId(submission.user_id), "Your submission was rejected.").await.ok(); + if let Some(mid) = submission.review_message_id { + bot.edit_message_text(chat_id, MessageId(mid), format!("[ IGNORED ] #{}\nIgnored by {}", submission_id, user_id)) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(Vec::>::new())) + .await.ok(); + } + forward_repo.update_status(submission_id, "ignored").await?; + } + "blk" => { + forward_repo.add_to_list(submission.forward_id, submission.user_id, "blacklist").await?; + if let Some(mid) = submission.review_message_id { + bot.edit_message_text(chat_id, MessageId(mid), format!("[ BLACKLISTED ] #{}\nBlacklisted by {}", submission_id, user_id)) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(Vec::>::new())) + .await.ok(); + } + forward_repo.update_status(submission_id, "blacklisted").await?; + } + _ => {} + } + + Ok(()) +} + fn extract_cxid(input: &str) -> Option { let re = regex::Regex::new(r"[?&]cxid=([a-zA-Z0-9]{12})").ok()?; if let Some(cap) = re.captures(input) { @@ -1110,6 +1863,44 @@ async fn is_admin_in_chat(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool { is_admin(bot, chat_id, user_id).await } +async fn handle_get_id_search( + bot: &Bot, + chat_id: ChatId, + query: &str, + _ctx: &BotContext, +) -> HandlerResult { + let query_lower = query.to_lowercase(); + let search_term = if query_lower.starts_with('@') { + &query_lower[1..] + } else { + &query_lower + }; + let mut matches = vec![]; + + // Try administrators first (bots usually can see them) + if let Ok(admins) = bot.get_chat_administrators(chat_id).await { + for member in admins { + let user = member.user; + let username = user.username.clone().unwrap_or_default().to_lowercase(); + let full_name = format!("{} {}", user.first_name, user.last_name.unwrap_or_default()); + let display = full_name.trim().to_lowercase(); + if username.contains(search_term) || display.contains(search_term) || user.id.0.to_string() == query { + let name = full_name.trim().to_string(); + matches.push(format!("• {} — {}", user.id.0, name)); + } + } + } + + // Limit to top 5 + if matches.is_empty() { + bot.send_message(chat_id, "No members found matching that query in this chat.").await?; + } else { + let result = matches.into_iter().take(5).collect::>().join("\n"); + bot.send_message(chat_id, format!("Found:\n{}", result)).parse_mode(ParseMode::Html).await?; + } + Ok(()) +} + async fn handle_admin_blacklist_uid( bot: &Bot, chat_id: ChatId, @@ -1143,3 +1934,55 @@ async fn handle_admin_whitelist_uid( } Ok(()) } + +fn generate_forward_code() -> String { + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + thread_rng().sample_iter(&Alphanumeric).take(16).map(char::from).collect() +} + +fn generate_direct_password() -> String { + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + thread_rng().sample_iter(&Alphanumeric).take(12).map(char::from).collect() +} + +fn parse_duration(parts: &[&str]) -> Result, String> { + // parts[0] = duration integer, parts[1] = unit + if parts.len() < 2 { + return Ok(None); // permanent + } + let amount: u64 = parts[0].parse().map_err(|_| "Invalid duration amount".to_string())?; + let unit = parts[1].to_lowercase(); + let multiplier = match unit.as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1, + "m" | "min" | "mins" | "minute" | "minutes" => 60, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600, + "d" | "day" | "days" => 86400, + "w" | "week" | "weeks" => 604800, + "mo" | "month" | "months" => 2592000, // 30 days + "y" | "year" | "years" => 31536000, // 365 days + _ => return Err(format!("Unknown time unit: {}", unit)), + }; + let total = amount.checked_mul(multiplier).ok_or("Duration overflow".to_string())?; + Ok(Some(total as i64)) +} + +async fn resolve_target_user_id( + bot: &Bot, + chat_id: ChatId, + arg: &str, +) -> Result, RequestError> { + if let Ok(id) = arg.parse::() { + return Ok(Some(id)); + } + let username = arg.strip_prefix('@').unwrap_or(arg); + // Fallback: search chat administrators + if let Ok(admins) = bot.get_chat_administrators(chat_id).await { + for admin in admins { + let user = &admin.user; + if user.username.as_deref() == Some(username) { + return Ok(Some(user.id.0)); + } + } + } + Ok(None) +} diff --git a/crates/cgcx-config/src/lib.rs b/crates/cgcx-config/src/lib.rs index c695e28..85f2807 100644 --- a/crates/cgcx-config/src/lib.rs +++ b/crates/cgcx-config/src/lib.rs @@ -100,6 +100,24 @@ pub struct RateLimitConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LoggingConfig { pub level: String, + #[serde(default = "default_file_enabled")] + pub file_enabled: bool, + #[serde(default = "default_file_path")] + pub file_path: String, + #[serde(default = "default_max_files")] + pub max_files: usize, +} + +fn default_file_enabled() -> bool { + true +} + +fn default_file_path() -> String { + "data/logs/cgcx-server.log".to_string() +} + +fn default_max_files() -> usize { + 7 } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/cgcx-content-typing/src/lib.rs b/crates/cgcx-content-typing/src/lib.rs index 361ecbc..aec38f1 100644 --- a/crates/cgcx-content-typing/src/lib.rs +++ b/crates/cgcx-content-typing/src/lib.rs @@ -7,9 +7,15 @@ pub const RENDER_DOCUMENT: u32 = 1 << 5; pub const RENDER_EXECUTABLE: u32 = 1 << 6; pub const RENDER_DANGEROUS: u32 = 1 << 7; pub const RENDER_NO_INLINE: u32 = 1 << 8; +pub const RENDER_SENSITIVE: u32 = 1 << 9; const DANGEROUS_EXTENSIONS: &[&str] = &[ "exe", "scr", "bat", "cmd", "sh", "dll", "so", "dylib", "jar", "msi", "com", "app", "apk", + "ps1", "py", "pyw", "vbs", "js", "html", "htm", +]; + +const SENSITIVE_EXTENSIONS: &[&str] = &[ + "db", "sqlite", "sqlite3", "sqlitedb", "mdf", "mdb", "accdb", "dump", "sql", "backup", "bak", ]; const DANGEROUS_MIME_TYPES: &[&str] = &[ @@ -18,6 +24,11 @@ const DANGEROUS_MIME_TYPES: &[&str] = &[ "text/css", "application/javascript", "application/ecmascript", + "application/x-python", + "text/x-python", + "application/x-powershell", + "application/x-shellscript", + "text/x-shellscript", ]; pub fn detect_mime_type(data: &[u8], file_name: &str) -> String { @@ -61,6 +72,10 @@ pub fn compute_render_flags(mime_type: &str, file_name: &str, data: &[u8]) -> u3 flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE; } + if SENSITIVE_EXTENSIONS.contains(&ext.as_str()) { + flags |= RENDER_SENSITIVE | RENDER_NO_INLINE; + } + if let Some(kind) = infer::get(data) { let mime = kind.mime_type(); if mime == "application/x-executable" diff --git a/crates/cgcx-core/src/models.rs b/crates/cgcx-core/src/models.rs index c4140a6..8940128 100644 --- a/crates/cgcx-core/src/models.rs +++ b/crates/cgcx-core/src/models.rs @@ -84,3 +84,45 @@ pub struct AdminAction { pub action: String, pub created_at: DateTime, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ForwardDefinition { + pub id: i64, + pub creator_user_id: i64, + pub source_chat_id: i64, + pub destination_chat_id: i64, + pub review_group_id: i64, + pub forward_message: String, + pub code: String, + pub share_mode: String, + pub revoked_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ForwardSubmission { + pub id: i64, + pub forward_id: i64, + pub user_id: i64, + pub content_id: ContentId, + pub status: String, + pub review_message_id: Option, + pub created_at: DateTime, + pub resolved_at: Option>, + pub resolver_id: Option, +} + +#[derive(Debug, Clone)] +pub struct Punishment { + pub id: i64, + pub chat_id: i64, + pub target_user_id: i64, + pub action_type: String, + pub duration_seconds: Option, + pub reason: Option, + pub created_by: i64, + pub created_at: String, + pub revoked_at: Option, + pub revoked_by: Option, + pub active: bool, +} diff --git a/crates/cgcx-db/src/lib.rs b/crates/cgcx-db/src/lib.rs index 090b362..e9f7f8a 100644 --- a/crates/cgcx-db/src/lib.rs +++ b/crates/cgcx-db/src/lib.rs @@ -47,6 +47,8 @@ impl Database { let migrations = rusqlite_migration::Migrations::new(vec![ rusqlite_migration::M::up(include_str!("../../../migrations/001_init.sql")), rusqlite_migration::M::up(include_str!("../../../migrations/002_indexes.sql")), + rusqlite_migration::M::up(include_str!("../../../migrations/003_forward_system.sql")), + rusqlite_migration::M::up(include_str!("../../../migrations/004_punishments.sql")), ]); migrations.to_latest(&mut *conn) .map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?; diff --git a/crates/cgcx-db/src/repos.rs b/crates/cgcx-db/src/repos.rs index 48a54b2..e6ec03d 100644 --- a/crates/cgcx-db/src/repos.rs +++ b/crates/cgcx-db/src/repos.rs @@ -1,5 +1,5 @@ -use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, Report, ReportStatus, Result, CgcxError, User}; -use rusqlite::{params, OptionalExtension}; +use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, ForwardDefinition, ForwardSubmission, Punishment, Report, ReportStatus, Result, CgcxError, User}; +use rusqlite::{params, OptionalExtension, Connection}; use std::sync::Arc; use tokio::sync::Mutex; @@ -197,6 +197,15 @@ impl ContentRepo { tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?; Ok(()) } + + pub async fn update_password_hash(&self, id: &ContentId, password_hash: Option<&str>) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE contents SET password_hash = ?1 WHERE id = ?2", + params![password_hash, id.as_str()], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } } pub struct ContentFileRepo { @@ -387,3 +396,304 @@ impl AdminActionRepo { Ok(conn.last_insert_rowid()) } } + +pub struct ForwardRepo { + conn: Arc>, +} + +impl ForwardRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert(&self, creator_user_id: i64, source_chat_id: i64, destination_chat_id: i64, review_group_id: i64, forward_message: &str, code: &str, share_mode: &str) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO forward_definitions (creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(conn.last_insert_rowid()) + } + + pub async fn get_by_code(&self, code: &str) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at + FROM forward_definitions WHERE code = ?1", + params![code], + |row| { + Ok(ForwardDefinition { + id: row.get(0)?, + creator_user_id: row.get(1)?, + source_chat_id: row.get(2)?, + destination_chat_id: row.get(3)?, + review_group_id: row.get(4)?, + forward_message: row.get(5)?, + code: row.get(6)?, + share_mode: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn list_by_source_chat(&self, source_chat_id: i64, limit: usize, offset: usize) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at + FROM forward_definitions WHERE source_chat_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map(params![source_chat_id, limit as i64, offset as i64], |row| { + Ok(ForwardDefinition { + id: row.get(0)?, + creator_user_id: row.get(1)?, + source_chat_id: row.get(2)?, + destination_chat_id: row.get(3)?, + review_group_id: row.get(4)?, + forward_message: row.get(5)?, + code: row.get(6)?, + share_mode: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(out) + } + + pub async fn revoke(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE forward_definitions SET revoked_at = datetime('now') WHERE id = ?1", + params![id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn is_allowed(&self, forward_id: i64, user_id: i64) -> Result { + let conn = self.conn.lock().await; + let def: Option = conn.query_row( + "SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at + FROM forward_definitions WHERE id = ?1", + params![forward_id], + |row| { + Ok(ForwardDefinition { + id: row.get(0)?, + creator_user_id: row.get(1)?, + source_chat_id: row.get(2)?, + destination_chat_id: row.get(3)?, + review_group_id: row.get(4)?, + forward_message: row.get(5)?, + code: row.get(6)?, + share_mode: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + + if let Some(def) = def { + if def.creator_user_id == user_id { + return Ok(true); + } + let list_entry: Option = conn.query_row( + "SELECT list_type FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2", + params![forward_id, user_id], + |row| row.get(0), + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + match def.share_mode.as_str() { + "w" => Ok(list_entry.map(|t| t == "allow").unwrap_or(false)), + _ => Ok(list_entry.map(|t| t != "block").unwrap_or(true)), + } + } else { + Ok(false) + } + } + + pub async fn insert_submission(&self, forward_id: i64, user_id: i64, content_id: &str) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO forward_submissions (forward_id, user_id, content_id) VALUES (?1, ?2, ?3)", + params![forward_id, user_id, content_id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(conn.last_insert_rowid()) + } + + pub async fn get_submission(&self, id: i64) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, forward_id, user_id, content_id, status, review_message_id, created_at, resolved_at, resolver_id + FROM forward_submissions WHERE id = ?1", + params![id], + |row| { + Ok(ForwardSubmission { + id: row.get(0)?, + forward_id: row.get(1)?, + user_id: row.get(2)?, + content_id: ContentId::new_unchecked(row.get(3)?), + status: row.get(4)?, + review_message_id: row.get(5)?, + created_at: row.get(6)?, + resolved_at: row.get(7)?, + resolver_id: row.get(8)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn set_review_message_id(&self, id: i64, message_id: i32) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE forward_submissions SET review_message_id = ?1 WHERE id = ?2", + params![message_id, id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn update_status(&self, id: i64, status: &str) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE forward_submissions SET status = ?1, resolved_at = datetime('now') WHERE id = ?2", + params![status, id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn get_definition(&self, id: i64) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at + FROM forward_definitions WHERE id = ?1", + params![id], + |row| { + Ok(ForwardDefinition { + id: row.get(0)?, + creator_user_id: row.get(1)?, + source_chat_id: row.get(2)?, + destination_chat_id: row.get(3)?, + review_group_id: row.get(4)?, + forward_message: row.get(5)?, + code: row.get(6)?, + share_mode: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn add_to_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO forward_lists (forward_id, user_id, list_type) VALUES (?1, ?2, ?3) ON CONFLICT DO NOTHING", + params![forward_id, user_id, list_type], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn remove_from_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "DELETE FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2 AND list_type = ?3", + params![forward_id, user_id, list_type], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } +} +pub struct PunishmentRepo { + conn: Arc>, +} + +impl PunishmentRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert( + &self, + chat_id: i64, + target_user_id: i64, + action_type: &str, + duration_seconds: Option, + reason: Option<&str>, + created_by: i64, + ) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO punishments (chat_id, target_user_id, action_type, duration_seconds, reason, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![chat_id, target_user_id, action_type, duration_seconds, reason, created_by], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(conn.last_insert_rowid()) + } + + pub async fn get_active_for_chat_target(&self, chat_id: i64, target_user_id: i64, action_type: &str) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE chat_id = ?1 AND target_user_id = ?2 AND action_type = ?3 AND active = 1" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map(params![chat_id, target_user_id, action_type], |row| { + Ok(Punishment { + id: row.get(0)?, + chat_id: row.get(1)?, + target_user_id: row.get(2)?, + action_type: row.get(3)?, + duration_seconds: row.get(4)?, + reason: row.get(5)?, + created_by: row.get(6)?, + created_at: row.get(7)?, + revoked_at: row.get(8)?, + revoked_by: row.get(9)?, + active: row.get::<_, i64>(10)? != 0, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut results = vec![]; + for r in rows { + results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(results) + } + + pub async fn revoke(&self, id: i64, revoked_by: i64) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE punishments SET active = 0, revoked_at = datetime('now'), revoked_by = ?1 WHERE id = ?2", + params![revoked_by, id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn list_expired(&self) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE active = 1 AND duration_seconds IS NOT NULL AND datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map([], |row| { + Ok(Punishment { + id: row.get(0)?, + chat_id: row.get(1)?, + target_user_id: row.get(2)?, + action_type: row.get(3)?, + duration_seconds: row.get(4)?, + reason: row.get(5)?, + created_by: row.get(6)?, + created_at: row.get(7)?, + revoked_at: row.get(8)?, + revoked_by: row.get(9)?, + active: row.get::<_, i64>(10)? != 0, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut results = vec![]; + for r in rows { + results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(results) + } +} diff --git a/crates/cgcx-server/Cargo.toml b/crates/cgcx-server/Cargo.toml index a1cc94e..ad3e2cc 100644 --- a/crates/cgcx-server/Cargo.toml +++ b/crates/cgcx-server/Cargo.toml @@ -21,6 +21,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression- tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/cgcx-server/src/main.rs b/crates/cgcx-server/src/main.rs index 8fc19c3..d49d867 100644 --- a/crates/cgcx-server/src/main.rs +++ b/crates/cgcx-server/src/main.rs @@ -26,6 +26,7 @@ use tower_http::{ trace::TraceLayer, }; use tracing::{info, warn}; +use tracing_subscriber::prelude::*; use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal; #[derive(Clone)] @@ -68,9 +69,42 @@ struct VerifyPasswordRequest { password: String, } +fn deserialize_download_bool<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct DownloadBoolVisitor; + + impl<'de> serde::de::Visitor<'de> for DownloadBoolVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or string representing a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result { + Ok(matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + } + + fn visit_string(self, v: String) -> Result { + self.visit_str(&v) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(v == 1) + } + } + + deserializer.deserialize_any(DownloadBoolVisitor) +} + #[derive(Deserialize)] struct FileQuery { - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_download_bool")] download: bool, #[serde(rename = "sc", default)] sc: Option, @@ -87,6 +121,11 @@ struct ByteRange { end: Option, } +enum AuthSource { + Cookie, + QueryParam, +} + struct AppError(CgcxError); impl From for AppError { @@ -119,7 +158,57 @@ type AppResult = Result; #[tokio::main] async fn main() -> cgcx_core::Result<()> { - tracing_subscriber::fmt::init(); + let config = Arc::new(Config::load()?); + config.validate()?; + + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&config.logging.level)); + + let console_layer = tracing_subscriber::fmt::layer(); + + let _file_guard = if config.logging.file_enabled { + let log_path = std::path::Path::new(&config.logging.file_path); + let log_dir = log_path.parent() + .and_then(|p| p.to_str()) + .unwrap_or("data/logs"); + let log_prefix = log_path.file_name() + .and_then(|f| f.to_str()) + .unwrap_or("cgcx-server.log"); + std::fs::create_dir_all(log_dir).ok(); + match tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(log_prefix) + .max_log_files(config.logging.max_files) + .build(log_dir) + { + Ok(file_appender) => { + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false); + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .with(file_layer) + .init(); + Some(guard) + } + Err(e) => { + tracing::warn!("Failed to create rolling file appender at {}: {}. Falling back to console only.", log_dir, e); + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .init(); + None + } + } + } else { + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .init(); + None + }; // Log panics so we can diagnose 500s that CatchPanicLayer swallows. std::panic::set_hook(Box::new(|info| { @@ -134,9 +223,6 @@ async fn main() -> cgcx_core::Result<()> { tracing::error!("PANIC at {}: {}", location, msg); })); - let config = Arc::new(Config::load()?); - config.validate()?; - let db_path = std::path::PathBuf::from(&config.database_path); if let Some(parent) = db_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); @@ -236,6 +322,7 @@ async fn main() -> cgcx_core::Result<()> { .route("/api/health", get(health)) .route("/api/content/:cxid", get(get_metadata)) .route("/api/content/:cxid/file/:file_idx", get(serve_file)) + .route("/api/content/:cxid/file/:file_idx/raw", get(serve_raw_file)) .merge(password_route) .nest_service("/assets", static_service) .fallback(fallback) @@ -299,7 +386,7 @@ async fn security_headers(req: axum::http::Request, next: Next) -> Respons let headers = response.headers_mut(); headers.insert( header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"), + HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; object-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"), ); headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); @@ -328,19 +415,19 @@ fn password_from_request( cxid: &str, password_hash: Option<&str>, cookie_secret: &[u8], -) -> bool { +) -> Option { 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; + return Some(AuthSource::QueryParam); } } } } - headers + if headers .get_all(header::COOKIE) .iter() .any(|v| { @@ -351,6 +438,31 @@ fn password_from_request( }) }).unwrap_or(false) }) + { + return Some(AuthSource::Cookie); + } + + None +} + +fn add_auth_cookie( + response: &mut Response, + auth_source: &Option, + cxid: &str, + cookie_secret: &[u8], +) -> AppResult<()> { + if let Some(AuthSource::QueryParam) = auth_source { + let cookie_value = make_cookie_value(cxid, cookie_secret); + let cookie = format!( + "cgcx_pw={}; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/", + cookie_value + ); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).map_err(|_| CgcxError::Storage("invalid cookie header".into()))?, + ); + } + Ok(()) } async fn get_metadata( @@ -378,12 +490,17 @@ async fn get_metadata( } } - if content.password_hash.is_some() { - 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()); + let auth_source = if content.password_hash.is_some() { + match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) { + Some(source) => Some(source), + None => { + tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid); + return Err(CgcxError::Unauthorized.into()); + } } - } + } else { + None + }; let file_repo = ContentFileRepo::new(state.db.conn()); let files = file_repo.list_by_content(&content_id).await?; @@ -403,11 +520,13 @@ async fn get_metadata( allow_download: content.allow_download, created_at: content.created_at.to_rfc3339(), }).map_err(|_| CgcxError::BadRequest("json serialization".into()))?; - Ok(Response::builder() + let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) - .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + Ok(response) } async fn verify_password( @@ -451,6 +570,22 @@ async fn verify_password( .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?) } +fn client_ip_from_headers(headers: &HeaderMap) -> String { + if let Some(xff) = headers.get("x-forwarded-for") { + if let Ok(s) = xff.to_str() { + if let Some(ip) = s.split(',').next() { + return ip.trim().to_string(); + } + } + } + if let Some(xri) = headers.get("x-real-ip") { + if let Ok(s) = xri.to_str() { + return s.trim().to_string(); + } + } + "unknown".to_string() +} + async fn serve_file( State(state): State, Path((cxid, file_idx)): Path<(String, u32)>, @@ -476,15 +611,22 @@ async fn serve_file( } } - if content.password_hash.is_some() { - 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()); + let auth_source = if content.password_hash.is_some() { + match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) { + Some(source) => Some(source), + None => { + let ip = client_ip_from_headers(&headers); + tracing::warn!("serve_file returning Unauthorized for cxid={} file_idx={} ip={}", cxid, file_idx, ip); + return Err(CgcxError::Unauthorized.into()); + } } - } + } else { + None + }; if query.download && !content.allow_download { - tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={}", cxid); + let ip = client_ip_from_headers(&headers); + tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={} file_idx={} ip={}", cxid, file_idx, ip); return Err(CgcxError::Forbidden.into()); } @@ -502,26 +644,30 @@ async fn serve_file( } else { format!("inline; filename=\"{}\"", sanitized_name) }; - return Ok(Response::builder() + let mut response = 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") + .header(header::CACHE_CONTROL, if content.password_hash.is_some() { "private, no-store, max-age=0" } else { "private, max-age=60" }) .body(Body::empty()) - .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + return Ok(response); } // Path traversal validation let canonical_path = tokio::fs::canonicalize(&file.stored_path).await .map_err(|e| { - tracing::error!("canonicalize failed for {:?}: {}", file.stored_path, e); + let ip = client_ip_from_headers(&headers); + tracing::error!("canonicalize failed for {:?}: {} | ip={} cxid={} file_idx={}", file.stored_path, e, ip, cxid, file_idx); CgcxError::Storage("invalid stored path".into()) })?; if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) { - tracing::error!("Path traversal blocked: {:?}", canonical_path); - tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid); + let ip = client_ip_from_headers(&headers); + tracing::error!("Path traversal blocked: {:?} | ip={} cxid={} file_idx={}", canonical_path, ip, cxid, file_idx); + tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={} file_idx={} ip={}", cxid, file_idx, ip); return Err(CgcxError::Forbidden.into()); } @@ -530,11 +676,13 @@ async fn serve_file( // If-None-Match check (skip increment) if let Some(inm) = headers.get(header::IF_NONE_MATCH) { if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) { - return Ok(Response::builder() + let mut response = Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, etag.clone()) .body(Body::empty()) - .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + return Ok(response); } } @@ -573,10 +721,12 @@ async fn serve_file( let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await; } repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?; - return Ok(Response::builder() + let mut response = Response::builder() .status(StatusCode::GONE) .body(Body::empty()) - .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + return Ok(response); } } } @@ -636,7 +786,140 @@ async fn serve_file( let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx); let body = Body::from_stream(body_stream); - Ok(response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?) + let mut response = response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + Ok(response) +} + +async fn serve_raw_file( + State(state): State, + Path((cxid, file_idx)): Path<(String, u32)>, + Query(query): Query, + headers: HeaderMap, +) -> AppResult { + tracing::info!("serve_raw_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_raw_file returning NotFound for cxid={}", cxid); + return Err(CgcxError::NotFound.into()); + } + + if let Some(max) = content.max_views { + if content.view_count >= max { + return Ok(Response::builder() + .status(StatusCode::GONE) + .body(Body::empty()) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?); + } + } + + let auth_source = if content.password_hash.is_some() { + match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) { + Some(source) => Some(source), + None => { + let ip = client_ip_from_headers(&headers); + tracing::warn!("serve_raw_file returning Unauthorized for cxid={} file_idx={} ip={}", cxid, file_idx, ip); + return Err(CgcxError::Unauthorized.into()); + } + } + } else { + None + }; + + let file_repo = ContentFileRepo::new(state.db.conn()); + let files = file_repo.list_by_content(&content_id).await?; + let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?; + + // Handle zero-size files early + if file.size_bytes == 0 { + let sanitized_name = sanitize_content_disposition(&file.original_name); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", sanitized_name)) + .header(header::CONTENT_LENGTH, "0") + .body(Body::empty()) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + return Ok(response); + } + + // Path traversal validation + let canonical_path = tokio::fs::canonicalize(&file.stored_path).await + .map_err(|e| { + let ip = client_ip_from_headers(&headers); + tracing::error!("canonicalize failed for {:?}: {} | ip={} cxid={} file_idx={}", file.stored_path, e, ip, cxid, file_idx); + CgcxError::Storage("invalid stored path".into()) + })?; + if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) { + let ip = client_ip_from_headers(&headers); + tracing::error!("Path traversal blocked: {:?} | ip={} cxid={} file_idx={}", canonical_path, ip, cxid, file_idx); + tracing::warn!("serve_raw_file returning Forbidden (path traversal) for cxid={} file_idx={} ip={}", cxid, file_idx, ip); + return Err(CgcxError::Forbidden.into()); + } + + // Decrypt entire file into memory + let mut f = tokio::fs::File::open(&file.stored_path).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + let mut header_buf = [0u8; 24]; + f.read_exact(&mut header_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + + let content_key = unwrap_content_key(&file.encrypted_key_wrapped, &state.master_key)?; + let header = sodiumoxide::crypto::secretstream::xchacha20poly1305::Header::from_slice(&header_buf) + .ok_or_else(|| CgcxError::Crypto("invalid header".into()))?; + let mut decrypt_stream = DecryptStream::new(&content_key, &header)?; + + let mut plaintext = Vec::with_capacity(file.size_bytes as usize); + let mut len_buf = [0u8; 4]; + let mut saw_final = false; + + loop { + if f.read_exact(&mut len_buf).await.is_err() { + break; // EOF at message boundary + } + let msg_len = u32::from_le_bytes(len_buf) as usize; + if msg_len > 50_000_000 { + return Err(AppError(CgcxError::Crypto("message length exceeds sanity bound".into()))); + } + let mut msg_buf = vec![0u8; msg_len]; + f.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + + match decrypt_stream.pull(&msg_buf) { + Ok((chunk, tag)) => { + plaintext.extend_from_slice(&chunk); + if tag == TagFinal { + saw_final = true; + break; + } + } + Err(e) => { + return Err(AppError(e)); + } + } + } + + if !saw_final { + return Err(AppError(CgcxError::Crypto("stream ended without Final tag".into()))); + } + + let computed_hash = decrypt_stream.finalize().to_vec(); + if computed_hash != file.encrypted_hash { + tracing::error!(target: "critical", "BLAKE3 integrity mismatch for raw file {:?}: expected {} got {}", file.stored_path, hex::encode(&file.encrypted_hash), hex::encode(&computed_hash)); + return Err(AppError(CgcxError::Crypto("BLAKE3 integrity mismatch".into()))); + } + + let text = String::from_utf8_lossy(&plaintext); + let sanitized_name = sanitize_content_disposition(&file.original_name); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", sanitized_name)) + .body(Body::from(text.into_owned())) + .map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?; + add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?; + Ok(response) } async fn stream_decrypted_file( @@ -644,7 +927,7 @@ async fn stream_decrypted_file( master_key: Arc, wrapped_key: Vec, tx: tokio::sync::mpsc::Sender, std::io::Error>>, - _range: Option, + range: Option, _file_size: u64, expected_hash: Vec, ) -> cgcx_core::Result<()> { @@ -658,6 +941,92 @@ async fn stream_decrypted_file( let mut decrypt_stream = DecryptStream::new(&content_key, &header)?; let mut len_buf = [0u8; 4]; + + if let Some(ref r) = range { + let range_start = r.start; + let range_len = r.end.map(|e| e - r.start + 1); + let mut skipped_plaintext: u64 = 0; + let mut sent: u64 = 0; + + loop { + if file.read_exact(&mut len_buf).await.is_err() { + break; // EOF at message boundary + } + let ciphertext_len = u32::from_le_bytes(len_buf) as usize; + if ciphertext_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 plaintext_len = ciphertext_len.saturating_sub(16) as u64; + + if skipped_plaintext + plaintext_len <= range_start { + // Advance the decrypt stream state by reading and decrypting the + // skipped chunk, then discarding the plaintext. XChaCha20-Poly1305 + // secretstream is stateful and must be processed sequentially. + let mut skip_buf = vec![0u8; ciphertext_len]; + file.read_exact(&mut skip_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + match decrypt_stream.pull(&skip_buf) { + Ok((_, tag)) => { + if tag == TagFinal { + break; + } + } + Err(e) => { + let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await; + return Err(e); + } + } + skipped_plaintext += plaintext_len; + continue; + } + + let trim_start = if skipped_plaintext < range_start { + (range_start - skipped_plaintext) as usize + } else { + 0 + }; + + let mut msg_buf = vec![0u8; ciphertext_len]; + file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + + match decrypt_stream.pull(&msg_buf) { + Ok((plaintext, tag)) => { + let start = trim_start.min(plaintext.len()); + let mut slice = &plaintext[start..]; + if let Some(max_total) = range_len { + let remaining = (max_total - sent) as usize; + if slice.len() > remaining { + slice = &slice[..remaining]; + } + } + if !slice.is_empty() { + if tx.send(Ok(slice.to_vec())).await.is_err() { + return Ok(()); // client disconnected + } + sent += slice.len() as u64; + } + if let Some(max_total) = range_len { + if sent >= max_total { + return Ok(()); + } + } + if tag == TagFinal { + break; + } + } + Err(e) => { + let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await; + return Err(e); + } + } + + skipped_plaintext += plaintext_len; + } + + return Ok(()); + } + + // Full-file streaming let mut saw_final = false; loop { if file.read_exact(&mut len_buf).await.is_err() { diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..06c6be8 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,134 @@ +# HTTP API Reference + +## Endpoints + +### GET /api/health +- **Description:** Health check endpoint. +- **Auth:** None +- **Query params:** None +- **Response:** `{"status":"ok"}` + +--- + +### GET /api/content/:cxid +- **Description:** Get content metadata (files, view limits, password status, etc.). +- **Auth:** + - None if the content has no password. + - Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`. +- **Path params:** + - `cxid` — Content ID string. +- **Query params:** + - `sc` (optional) — Password as a query string parameter. +- **Response formats:** + - `200 OK` — JSON metadata object: + ```json + { + "cxid": "string", + "files": [ + { + "idx": 0, + "name": "string", + "mime": "string", + "size": 12345, + "render_flags": 0 + } + ], + "has_password": true, + "max_views": 10, + "current_views": 3, + "allow_download": true, + "created_at": "2024-01-01T00:00:00+00:00" + } + ``` + - `401 Unauthorized` — Password required but missing or invalid. + - `404 Not Found` — Content does not exist or has been deleted/blacklisted. + - `410 Gone` — Content has reached its maximum view count. +- **Notes:** + - If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response (`Max-Age=3600; SameSite=Strict; HttpOnly; Path=/`). + +--- + +### GET /api/content/:cxid/file/:file_idx +- **Description:** Serve a decrypted file. Supports HTTP Range requests for video/audio streaming. Returns the file with `Content-Disposition: inline` by default, or `attachment` when downloading. +- **Auth:** + - None if the content has no password. + - Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`. +- **Path params:** + - `cxid` — Content ID string. + - `file_idx` — Zero-based file index within the content bundle. +- **Query params:** + - `sc` (optional) — Password as a query string parameter. + - `download` (optional) — If truthy (`1`, `true`, `yes`), requests a download (`Content-Disposition: attachment`). Ignored if `allow_download` is `false` for the content. +- **Response formats:** + - `200 OK` — File stream with appropriate `Content-Type`. + - `206 Partial Content` — Byte-range response (if `Range` header is present and valid). + - `401 Unauthorized` — Password required but missing or invalid. + - `403 Forbidden` — Download requested but not allowed, or path traversal blocked. + - `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted. + - `410 Gone` — Content has reached its maximum view count. + - `416 Range Not Satisfiable` — Invalid `Range` header. +- **Notes:** + - The server increments the view counter on successful full-file responses. Range requests and `If-None-Match` (ETag) matches do **not** increment the counter. + - If the incremented view count reaches `max_views`, the server may delete content files (depending on `keep_content` config) and mark the content as `Deleted`, returning `410 Gone`. + - `Accept-Ranges: bytes` is included for `video/*` and `audio/*` MIME types. + - Cache-Control is `private, max-age=60` for unprotected content and `private, no-store, max-age=0` for password-protected content. + - If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response. + +--- + +### GET /api/content/:cxid/file/:file_idx/raw +- **Description:** Serve the fully decrypted file as raw plain text (`text/plain; charset=utf-8`). The entire file is decrypted into memory before being returned. No Range support. +- **Auth:** + - None if the content has no password. + - Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`. +- **Path params:** + - `cxid` — Content ID string. + - `file_idx` — Zero-based file index within the content bundle. +- **Query params:** + - `sc` (optional) — Password as a query string parameter. +- **Response formats:** + - `200 OK` — Plain text body. + - `401 Unauthorized` — Password required but missing or invalid. + - `403 Forbidden` — Path traversal blocked. + - `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted. + - `410 Gone` — Content has reached its maximum view count. +- **Notes:** + - The server performs BLAKE3 integrity verification after full decryption. + - If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response. + +--- + +### POST /api/content/:cxid/verify-password +- **Description:** Explicitly verify a password for password-protected content and receive an authentication cookie. +- **Auth:** None (this is the endpoint used to *obtain* auth). +- **Path params:** + - `cxid` — Content ID string. +- **Body:** JSON object: + ```json + { + "password": "string" + } + ``` +- **Response formats:** + - `204 No Content` — Password is correct. The response includes a `Set-Cookie` header with `cgcx_pw`. + - `401 Unauthorized` — Password is incorrect. + - `404 Not Found` — Content does not exist. +- **Notes:** + - If the content has no password, the endpoint returns `204 No Content` without setting a cookie. + - This endpoint has a separate, stricter rate limit than the general API. + +--- + +## General Behavior + +### CORS +The server allows cross-origin requests from its configured `base_url` and common local development origins (`http://127.0.0.1:5173`, `http://localhost:5173`, `http://127.0.0.1:8090`, `http://localhost:8090`). + +### Rate Limiting +- General API routes (`/api/health`, `/api/content/...`) share a per-IP rate limit configured by `requests_per_minute` and `burst`. +- `POST /api/content/:cxid/verify-password` has its own rate limit with a burst of 3 and a separate `password_attempts_per_minute` setting. + +### Fallback / Static Assets +- `/assets/*` — Serves static files from `frontend/dist/assets`. +- All other non-`/api` paths — Serves `frontend/dist/index.html` (SPA fallback). +- `/api/*` paths with no matching route — Return `404 Not Found` JSON. diff --git a/docs/AUTH_FLOW.md b/docs/AUTH_FLOW.md new file mode 100644 index 0000000..6a6ef33 --- /dev/null +++ b/docs/AUTH_FLOW.md @@ -0,0 +1,194 @@ +# Authentication Flow + +cg.cx uses a simple password-and-cookie authentication model. There is no user account system; access is granted per-content-item by knowing its password. + +--- + +## AuthSource Enum + +The server tracks *how* a request was authenticated using the `AuthSource` enum: + +```rust +enum AuthSource { + Cookie, // Request presented a valid cgcx_pw cookie + QueryParam, // Request presented a correct ?sc=PASSWORD query param +} +``` + +This distinction matters because the server only issues a **new** cookie when auth succeeds via `QueryParam`. Cookie-based auth does not re-issue the cookie (the browser already has it). + +--- + +## How Direct Links with `?sc=PASSWORD` Work + +1. A client requests a protected endpoint (e.g., `GET /api/content/:cxid` or `GET /api/content/:cxid/file/:file_idx`) with the password in the query string: + ``` + GET /api/content/abc123?sc=MySecretPassword + ``` +2. The handler calls `password_from_request(...)`. +3. Inside `password_from_request`, the `sc` query parameter is checked **first**: + - If `sc` is present and the content has a `password_hash`, the server verifies the supplied password against the stored Argon2 hash. + - If verification succeeds, `password_from_request` returns `Some(AuthSource::QueryParam)`. +4. The handler proceeds with the request. +5. On the response path, the handler calls `add_auth_cookie(...)`. +6. Because the auth source is `QueryParam`, `add_auth_cookie` generates a new HMAC-signed `cgcx_pw` cookie and attaches it via a `Set-Cookie` header. +7. The client’s browser stores the cookie. Subsequent requests to the same domain automatically include it, so the `?sc=...` parameter is no longer needed. + +--- + +## How the Server Validates Passwords (Argon2) + +Password verification happens in two places: `password_from_request` and the `verify_password` endpoint. + +### Stored Hash Format +Password hashes are stored in the database as Argon2id hashes in the standard PHC string format (e.g., `$argon2id$v=19$m=65536,t=3,p=4$...`). + +### Verification +```rust +use argon2::{Argon2, PasswordHash, PasswordVerifier}; + +let parsed_hash = PasswordHash::new(&hash)?; +let valid = Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok(); +``` + +- Uses the default Argon2 parameters. +- If parsing the stored hash fails, verification is treated as failed. +- If verification fails, the server returns `401 Unauthorized`. + +--- + +## How Cookies Are Set After Successful Query-Param Auth + +After a successful `?sc=...` authentication, the response includes: + +```http +Set-Cookie: cgcx_pw=; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/ +``` + +- **Name:** `cgcx_pw` +- **Max-Age:** 3600 seconds (1 hour) +- **SameSite:** `Strict` +- **HttpOnly:** `true` (not accessible to JavaScript) +- **Path:** `/` + +The cookie value is generated by `make_cookie_value(cxid, cookie_secret)` (see [Cookie Format](#cookie-format-hmac-signed) below). + +The same cookie is also set by the `POST /api/content/:cxid/verify-password` endpoint after a successful JSON password verification. + +--- + +## How Subsequent Requests Use the Cookie + +1. The browser automatically sends the `cgcx_pw` cookie on every request to the same origin. +2. When `password_from_request` is called: + - It first checks for `sc` query param auth. + - If that fails or is absent, it scans **all** `Cookie` headers (there may be multiple). + - For each cookie header, it splits on `;` and looks for a part starting with `cgcx_pw=`. + - When found, it calls `verify_cookie(cxid, &part[8..], cookie_secret)`. + - If `verify_cookie` returns `true`, `password_from_request` returns `Some(AuthSource::Cookie)`. +3. Because the auth source is `Cookie`, `add_auth_cookie` does **not** add a new `Set-Cookie` header. + +--- + +## Cookie Format (HMAC-Signed) + +The cookie value is a Base64-encoded string of the form: + +``` +base64(cxid + ":" + hmac_sha256(cxid, secret)) +``` + +### Building the Cookie (`make_cookie_value`) + +```rust +fn make_cookie_value(cxid: &str, secret: &[u8]) -> String { + let mac = hmac_cookie(cxid, secret); // HMAC-SHA256 of cxid + let mut raw = Vec::with_capacity(cxid.len() + 1 + mac.len()); + raw.extend_from_slice(cxid.as_bytes()); + raw.push(b':'); + raw.extend_from_slice(&mac); + base64::engine::general_purpose::STANDARD.encode(&raw) +} +``` + +### Verifying the Cookie (`verify_cookie`) + +```rust +fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool { + let decoded = base64_decode(cookie_value)?; + let mut parts = decoded.splitn(2, |&b| b == b':'); + let decoded_cxid = std::str::from_utf8(parts.next()?)?; + let mac_bytes = parts.next()?; + + if decoded_cxid != cxid { + return false; + } + + let expected = hmac_cookie(cxid, secret); + if mac_bytes.len() != expected.len() { + return false; + } + + // Constant-time comparison to prevent timing attacks + mac_bytes.ct_eq(&expected).into() +} +``` + +### Cookie Secret + +The HMAC key (`cookie_secret`) is derived from the master encryption key at startup: + +```rust +let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec(); +``` + +This means: +- The cookie secret is deterministic for a given master key. +- Cookies are bound to a specific server instance (or cluster sharing the same master key). +- Changing the master key invalidates all existing auth cookies. + +### Security Properties +- **Binding:** The HMAC includes the `cxid`, so a cookie for one piece of content cannot be replayed against another. +- **Tamper resistance:** Without the `cookie_secret`, an attacker cannot forge a valid `cgcx_pw` cookie. +- **Timing safety:** Verification uses `subtle::ConstantTimeEq` to avoid leaking information via timing side channels. + +--- + +## Summary Flow Diagram + +``` +Client Request + │ + ▼ +┌─────────────────────────────────────────┐ +│ Does the content have a password? │ +└─────────────────────────────────────────┘ + │ + ├─ No ──► Proceed (no auth needed) + │ + └─ Yes + │ + ▼ +┌─────────────────────────────────────────┐ +│ Is ?sc=PASSWORD present and correct? │ +│ └─ Argon2 verify against stored hash │ +└─────────────────────────────────────────┘ + │ + ├─ Yes ──► AuthSource::QueryParam + │ └─ Set cgcx_pw cookie on response + │ + └─ No + │ + ▼ +┌─────────────────────────────────────────┐ +│ Is cgcx_pw cookie present and valid? │ +│ └─ HMAC-SHA256 verify against secret │ +└─────────────────────────────────────────┘ + │ + ├─ Yes ──► AuthSource::Cookie + │ └─ Proceed without setting new cookie + │ + └─ No ──► 401 Unauthorized +``` diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..54d0ba9 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,106 @@ +# Bot Commands + +This document lists all commands and callback actions implemented in `crates/cgcx-bot/src/main.rs`. + +--- + +## Admin Commands (Group-only) + +All admin commands require the caller to be an **administrator or owner** of the group. + +| Command | Args | Description | +|---------|------|-------------| +| `/reload` | none | Reload moderation lists from disk. | +| `/blacklist_uid` | `` | Blacklist a user by Telegram ID globally and set their role to `banned`. | +| `/whitelist_uid` | `` | Remove a user from the global blacklist and restore their role to `user`. | +| `/help` | none | Show the admin help message listing all admin commands. | +| `/get_id` | none | Get the current group chat ID. | +| `/get_id` | `<@username>` | Search administrators in this chat by username. | +| `/get_id` | `` | Search members in this chat by display name. | +| `/create_submit_forward` | ` [forward_message]` | Create a submission forward link. Bot must be admin in both destination and review groups. | +| `/show_c_forward` | `[page]` | List active forward links for this chat with pagination. | +| `/add_blacklist` | `` | Blacklist a user in **all active forwards** for this source chat. | +| `/rm_blacklist` | `` | Remove a user from the blacklist in **all active forwards** for this source chat. | +| `/sban` | `@user [reason]` | Ban a user for a specified duration. | +| `/smute` | `@user [reason]` | Mute a user for a specified duration. | +| `/mute` | `@user [reason]` | Mute a user indefinitely. | +| `/pban` | `@user [reason]` | Permanently ban a user. | +| `/kick` | `@user [reason]` | Kick a user from the group. | +| `/rmute` | `@user` | Revoke an active mute and restore the user's chat permissions. | +| `/rban` | `@user` | Revoke an active ban and unban the user. | + +--- + +## User Commands (DM) + +| Command | Args | Description | +|---------|------|-------------| +| `/start` | none | Start the bot. Displays terms if not accepted, otherwise shows the main menu. | +| `/start` | `submitfwdid` | Deep-link entry into **Submission Mode** for a forward. | +| `/cancel` | none | Cancel the current operation and return to the main menu. | + +--- + +## Callback Actions + +Callbacks use the format `v1::[:]`. + +### Terms +| Callback | Description | +|----------|-------------| +| `v1:terms:accept` | Accept the terms of service. | +| `v1:terms:reject` | Reject the terms of service. | + +### Main Menu +| Callback | Description | +|----------|-------------| +| `v1:menu:upload_media` | Enter media upload staging. | +| `v1:menu:upload_doc` | Enter document upload staging. | +| `v1:menu:upload_text` | Enter text upload staging. | +| `v1:menu:prev_uploads` | View previous uploads. | +| `v1:menu:report` | Enter content reporting flow. | +| `v1:menu:main` | Return to main menu. | + +### Staging +| Callback | Description | +|----------|-------------| +| `v1:stage:confirm` | Confirm staged items and proceed to upload options. | +| `v1:stage:cancel` | Cancel the upload and return to main menu. | + +### Upload Options +| Callback | Description | +|----------|-------------| +| `v1:opt:toggle_destroy` | Cycle auto-destroy max views (Off → 1 → 3 → 5 → 10 → 50 → Off). | +| `v1:opt:toggle_download` | Toggle the "allow download" flag. | +| `v1:opt:set_password` | Prompt user to send a password (or `/skip`). | +| `v1:opt:confirm_final` | Confirm options and finalize the upload. | +| `v1:opt:back` | Go back to upload staging. | + +### Previous Uploads +| Callback | Description | +|----------|-------------| +| `v1:prev:page:{page}` | Navigate to a specific page of previous uploads. | + +### Submission Mode +| Callback | Description | +|----------|-------------| +| `v1:submit:continue` | Continue into submission upload flow. | +| `v1:submit:exit` | Exit submission mode and return to main menu. | + +### Admin / Moderation +| Callback | Description | +|----------|-------------| +| `v1:admin:delcontent:{cxid}` | Delete a content item by its CXID. | +| `v1:admin:delblk:{report_id}` | Delete reported content and blacklist the uploader. | +| `v1:admin:del:{report_id}` | Delete reported content only. | +| `v1:admin:blk:{report_id}` | Blacklist the uploader of reported content only. | +| `v1:admin:ign:{report_id}` | Ignore/dismiss the report. | + +### Forward Submissions +| Callback | Description | +|----------|-------------| +| `v1:fwd:approve:{submission_id}` | Approve a forward submission and post it to the destination chat. | +| `v1:fwd:ignore:{submission_id}` | Reject a forward submission. | +| `v1:fwd:blk:{submission_id}` | Blacklist the submitting user from the forward. | +| `v1:fwd:revoke:{forward_id}` | Revoke a forward link. | +| `v1:fwd:page:{page}` | Navigate forward link list pages. | diff --git a/docs/FORWARD_SYSTEM.md b/docs/FORWARD_SYSTEM.md new file mode 100644 index 0000000..5fb2877 --- /dev/null +++ b/docs/FORWARD_SYSTEM.md @@ -0,0 +1,223 @@ +# Forward Submission System + +This document describes the submission-forward flow that allows users to upload content through the bot for moderator review before it is posted to a destination channel or group. + +--- + +## Database Schema + +Defined in `migrations/003_forward_system.sql`. + +### `forward_definitions` + +```sql +CREATE TABLE forward_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creator_user_id INTEGER NOT NULL, + source_chat_id INTEGER NOT NULL, + destination_chat_id INTEGER NOT NULL, + review_group_id INTEGER NOT NULL, + forward_message TEXT NOT NULL DEFAULT '', + code TEXT NOT NULL UNIQUE, + share_mode TEXT NOT NULL DEFAULT 'b', + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT datetime('now') +); +``` + +| Field | Description | +|-------|-------------| +| `id` | Primary key. | +| `creator_user_id` | Telegram ID of the admin who created the forward. | +| `source_chat_id` | The group/chat where `/create_submit_forward` was invoked. | +| `destination_chat_id` | The target channel/group where approved content is posted. | +| `review_group_id` | The moderator group where submissions are sent for review. | +| `forward_message` | Optional template text prepended to approved posts. | +| `code` | Unique 16-character alphanumeric access code. | +| `share_mode` | `'b'` = blacklist mode (default), `'w'` = whitelist mode. | +| `revoked_at` | Timestamp if the forward link was revoked; `NULL` while active. | + +**Indexes:** `idx_forward_code`, `idx_forward_source`. + +### `forward_submissions` + +```sql +CREATE TABLE forward_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + forward_id INTEGER NOT NULL REFERENCES forward_definitions(id), + user_id INTEGER NOT NULL, + content_id TEXT NOT NULL REFERENCES contents(id), + status TEXT NOT NULL DEFAULT 'pending', + review_message_id INTEGER, + created_at TEXT NOT NULL DEFAULT datetime('now'), + resolved_at TEXT, + resolver_id INTEGER +); +``` + +| Field | Description | +|-------|-------------| +| `id` | Primary key (submission number). | +| `forward_id` | The forward this submission belongs to. | +| `user_id` | Telegram ID of the submitting user. | +| `content_id` | The uploaded content entry (`contents.id`). | +| `status` | `pending`, `approved`, `ignored`, or `blacklisted`. | +| `review_message_id` | Telegram message ID of the review post in the review group. | +| `resolved_at` | Timestamp when a moderator acted on the submission. | +| `resolver_id` | Telegram ID of the moderator who resolved it. | + +**Indexes:** `idx_fwd_sub_forward`, `idx_fwd_sub_user`, `idx_fwd_sub_status`. + +### `forward_lists` + +```sql +CREATE TABLE forward_lists ( + forward_id INTEGER NOT NULL REFERENCES forward_definitions(id), + user_id INTEGER NOT NULL, + list_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT datetime('now'), + PRIMARY KEY (forward_id, user_id, list_type) +); +``` + +| Field | Description | +|-------|-------------| +| `forward_id` | The forward definition. | +| `user_id` | The affected user. | +| `list_type` | `blacklist` or `allow`. | + +This table implements **per-forward** scoped access control. + +--- + +## Creating a Forward (`/create_submit_forward`) + +**Usage (group-only, admin-gated):** +``` +/create_submit_forward [forward_message] +``` + +**Requirements:** +1. Caller must be an **administrator or owner** of the source group. +2. The bot must be an **administrator** in both the `destination_chat_id` and `review_group_id`. + +**What happens:** +1. A 16-character alphanumeric `code` is generated (`generate_forward_code`). +2. A row is inserted into `forward_definitions` with: + - `source_chat_id` = current chat + - `share_mode` = `'b'` (blacklist mode) +3. The bot replies with a deep-link URL: + ``` + https://t.me/?start=submitfwdid + ``` + +--- + +## Entering Submission Mode + +Users click the deep link or send: +``` +/start submitfwdid +``` + +**Validation:** +1. The code is looked up in `forward_definitions`. +2. If the forward has been revoked (`revoked_at IS NOT NULL`), the user is told the link is revoked. +3. The scoped access check `ForwardRepo::is_allowed(forward_id, user_id)` is performed: + - The creator is always allowed. + - In **blacklist mode** (`'b'`): allowed unless the user has a `blacklist` entry. + - In **whitelist mode** (`'w'`): allowed only if the user has an `allow` entry. +4. If allowed, the bot enters `BotState::SubmitMode { forward_id, code }` and presents **Continue / Exit** buttons. + +--- + +## Submission Flow + +1. **Continue** — The user is transitioned to `BotState::MainMenu { pending_forward_id: Some(forward_id) }`. All uploads staged from this point are tagged with the pending forward ID. +2. **Upload** — The user stages files (media, documents, or text) and confirms options just like a normal upload. +3. **Finalize** — When the user confirms, `finalize_upload`: + - Creates and encrypts the content entry. + - Inserts a row into `forward_submissions` with `status = 'pending'`. + - Posts a review message to the `review_group_id` with inline buttons: + - `[ Approve ]` → callback `v1:fwd:approve:{submission_id}` + - `[ Ignore ]` → callback `v1:fwd:ignore:{submission_id}` + - `[ Blacklist User ]` → callback `v1:fwd:blk:{submission_id}` + - Stores the sent message ID back into `forward_submissions.review_message_id`. +4. **Review** — Moderators in the review group click the buttons to act. + +--- + +## Review Actions + +All review callbacks require the clicking user to be an **administrator in the review group** (`is_admin_in_chat`). + +### Approve (`v1:fwd:approve`) + +1. Generates a random 12-character direct-access password (`generate_direct_password`). +2. Hashes the password with Argon2 and stores it in `contents.password_hash`. +3. Builds the direct link: `{base_url}/?cxid={content_id}&sc={password}`. +4. Posts the link to the destination chat, prefixed with `forward_message` (if set). +5. DM the submitter: + - "Your submission was approved." + - Includes the posted message URL and the direct access link. +6. Edits the review message to show `[ APPROVED ]` and the moderator ID. +7. Sets `forward_submissions.status = 'approved'`. + +### Ignore (`v1:fwd:ignore`) + +1. DM the submitter: "Your submission was rejected." +2. Edits the review message to show `[ IGNORED ]` and the moderator ID. +3. Sets `forward_submissions.status = 'ignored'`. + +### Blacklist User (`v1:fwd:blk`) + +1. Adds the submitter to `forward_lists` with `list_type = 'blacklist'` for this forward. +2. Edits the review message to show `[ BLACKLISTED ]` and the moderator ID. +3. Sets `forward_submissions.status = 'blacklisted'`. +4. The user is now blocked from using this forward link again (until removed). + +--- + +## Management Commands + +All group-only, admin-gated. + +### `/show_c_forward [page]` + +Lists forward links created in the current source chat (5 per page). +- Shows code, destination chat ID, review group ID, and status (`Active` or `Revoked`). +- Active forwards include an inline `[ Revoke ]` button. +- Pagination via `<<` / `>>` buttons. + +### `/add_blacklist ` + +Iterates all **active** forwards for the current source chat and inserts the user into each forward's `forward_lists` as `blacklist`. +Replies with the count of forwards affected. + +### `/rm_blacklist ` + +Iterates all **active** forwards for the current source chat and removes the user from each forward's `forward_lists` where `list_type = 'blacklist'`. +Replies with the count of forwards affected. + +--- + +## Scoped Access Model + +Each forward has its own independent access list stored in `forward_lists`. + +| `share_mode` | Behavior | +|--------------|----------| +| `'b'` (blacklist) | **Default.** Everyone is allowed unless explicitly blacklisted. | +| `'w'` (whitelist) | Only explicitly allowed users (and the creator) may submit. | + +**Note:** The `share_mode` is stored per forward but there is currently no admin command to change it after creation; it defaults to `'b'` at creation time. + +--- + +## Revoking a Forward + +Admins or the creator can revoke a forward via the `[ Revoke ]` button on `/show_c_forward`. +- Validates the forward belongs to the current chat. +- Requires creator status **or** admin status. +- Sets `revoked_at = datetime('now')`. +- Revoked forwards reject new submissions immediately. diff --git a/docs/MODERATION.md b/docs/MODERATION.md new file mode 100644 index 0000000..6073520 --- /dev/null +++ b/docs/MODERATION.md @@ -0,0 +1,149 @@ +# Moderation & Punishment System + +This document describes the punishment system implemented in the bot. + +--- + +## Database Schema + +Defined in `migrations/004_punishments.sql`. + +```sql +CREATE TABLE punishments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick' + duration_seconds INTEGER, -- NULL = permanent / indefinite + reason TEXT, + created_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT datetime('now'), + revoked_at TEXT, + revoked_by INTEGER, + active INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id); +CREATE INDEX idx_punishments_active ON punishments(active); +``` + +### Field Reference + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `INTEGER` | Primary key. | +| `chat_id` | `INTEGER` | The Telegram group/chat where the punishment was applied. | +| `target_user_id` | `INTEGER` | The punished user's Telegram ID. | +| `action_type` | `TEXT` | One of `ban`, `mute`, or `kick`. | +| `duration_seconds` | `INTEGER` | Duration before auto-expiration. `NULL` means **permanent / indefinite**. | +| `reason` | `TEXT` | Optional moderator-provided reason. | +| `created_by` | `INTEGER` | Telegram ID of the moderator who issued the punishment. | +| `created_at` | `TEXT` | ISO timestamp when the punishment was created. | +| `revoked_at` | `TEXT` | ISO timestamp when the punishment was manually or automatically revoked. | +| `revoked_by` | `INTEGER` | Telegram ID of the revoker, or `0` for the system (auto-expiry). | +| `active` | `INTEGER` | `1` if the punishment is still in effect, `0` if revoked. | + +--- + +## Command Reference + +All punishment commands require the caller to be an **administrator or owner** of the group. + +### Duration-based Punishments + +| Command | Syntax | Description | +|---------|--------|-------------| +| `/sban` | `/sban @user [reason]` | Ban the user for a specific duration. | +| `/smute` | `/smute @user [reason]` | Mute the user for a specific duration. | + +### Permanent / Indefinite Punishments + +| Command | Syntax | Description | +|---------|--------|-------------| +| `/mute` | `/mute @user [reason]` | Mute the user **indefinitely** (`duration_seconds = NULL`). | +| `/pban` | `/pban @user [reason]` | Permanently ban the user (`duration_seconds = NULL`). | +| `/kick` | `/kick @user [reason]` | Kick the user from the group. Always recorded with `NULL` duration. | + +### Revoke Commands + +| Command | Syntax | Description | +|---------|--------|-------------| +| `/rmute` | `/rmute @user` | Revoke the active mute for this user and restore chat permissions. | +| `/rban` | `/rban @user` | Revoke the active ban for this user and unban them. | + +### Target Resolution + +The `@user` argument is resolved in the following order: +1. Numeric user ID. +2. `@username` — matched against chat administrators. + +If the target cannot be resolved, the bot replies with *"Could not resolve target user."* + +--- + +## Duration Units Reference + +The `parse_duration` function in `crates/cgcx-bot/src/main.rs` accepts the following units (case-insensitive): + +| Unit(s) | Seconds | Example | +|---------|---------|---------| +| `s`, `sec`, `secs`, `second`, `seconds` | 1 | `/sban @user 30 s spam` | +| `m`, `min`, `mins`, `minute`, `minutes` | 60 | `/smute @user 10 m offtopic` | +| `h`, `hr`, `hrs`, `hour`, `hours` | 3,600 | `/sban @user 24 h raid` | +| `d`, `day`, `days` | 86,400 | `/sban @user 7 d trolling` | +| `w`, `week`, `weeks` | 604,800 | `/smute @user 2 w` | +| `mo`, `month`, `months` | 2,592,000 (30 days) | `/sban @user 1 mo` | +| `y`, `year`, `years` | 31,536,000 (365 days) | `/pban @user 1 y` | + +--- + +## How Expiration Works (Background Task) + +When the bot starts, a background Tokio task is spawned that runs every **60 seconds**: + +1. **Query** — Calls `PunishmentRepo::list_expired()`, which selects rows where: + - `active = 1` + - `duration_seconds IS NOT NULL` + - `datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')` + +2. **Action per expired punishment**: + - **`ban`** — Calls `unban_chat_member(chat_id, target_user_id)` to lift the ban. + - **`mute`** — Calls `restrict_chat_member` with restored permissions: + - `SEND_MESSAGES` + - `SEND_MEDIA_MESSAGES` + - `SEND_OTHER_MESSAGES` + - `ADD_WEB_PAGE_PREVIEWS` + - **Other types** — No automatic Telegram action is taken. + +3. **Record update** — Calls `repo.revoke(p.id, 0)`: + - Sets `active = 0` + - Sets `revoked_at = datetime('now')` + - Sets `revoked_by = 0` (system) + +--- + +## How Revoke Works + +### Manual Revoke (`/rmute`, `/rban`) + +1. The bot resolves the target user. +2. Queries `get_active_for_chat_target(chat_id, target_user_id, action_type)` to find the active punishment. +3. If found: + - **`/rmute`** — Restores the user's chat permissions via `restrict_chat_member(...)`. + - **`/rban`** — Unbans the user via `unban_chat_member(...)`. + - Calls `repo.revoke(p.id, admin_user_id)` to mark the punishment inactive. +4. If no active punishment is found, the bot replies with *"No active mute/ban found for this user."* + +### Revoke via `PunishmentRepo::revoke(id, revoked_by)` + +```sql +UPDATE punishments +SET active = 0, + revoked_at = datetime('now'), + revoked_by = ?1 +WHERE id = ?2; +``` + +This is used both for: +- **Automatic expiration** (`revoked_by = 0`) +- **Manual moderator revocation** (`revoked_by = moderator_user_id`) diff --git a/docs/OPERATIONAL_NOTES.md b/docs/OPERATIONAL_NOTES.md new file mode 100644 index 0000000..b1902c5 --- /dev/null +++ b/docs/OPERATIONAL_NOTES.md @@ -0,0 +1,137 @@ +# Operational Notes + +This document covers runtime behaviors, limits, and maintenance considerations for operating a cg.cx instance. + +--- + +## Telegram API Rate Limits + +The bot does **not** implement explicit request throttling for Telegram API calls. It relies on Teloxide's default behavior and the Telegram Bot API flood-control semantics. + +- **Forwarding / posting messages** — Subject to standard Bot API rate limits (roughly ~30 messages/second in groups, lower in smaller chats). Rapid approval of many submissions may trigger `RetryAfter` errors; the bot currently does not back off explicitly. +- **Banning / restricting members** — `banChatMember` and `restrictChatMember` have aggressive per-chat limits. Issuing many punishment commands in quick succession may result in temporary API rejections. +- **Message deletion** — `deleteMessage` is limited to ~300 deletions per chat per 24 hours for bots. The automatic service-message cleanup (see below) contributes to this budget. + +**Operational recommendation:** If running in high-traffic groups, monitor bot logs for `RetryAfter` or `429` errors and consider spacing out bulk operations. + +--- + +## System Message Deletion Limits + +The bot automatically deletes service messages in groups and channels to reduce noise. In `handle_message_inner`, the following 17 message types are detected and deleted in non-private chats: + +- `new_chat_members` +- `left_chat_member` +- `new_chat_title` +- `new_chat_photo` +- `delete_chat_photo` +- `group_chat_created` +- `supergroup_chat_created` +- `channel_chat_created` +- `migrate_to_chat_id` +- `migrate_from_chat_id` +- `pinned_message` +- `video_chat_scheduled` +- `video_chat_started` +- `video_chat_ended` +- `video_chat_participants_invited` +- `message_auto_delete_timer_changed` +- `proximity_alert_triggered` + +**Limitations:** +- Some service messages (e.g., `channel_chat_created`) **cannot be deleted by bots** and will silently fail. The code handles this with `let _ = bot.delete_message(...).await;`. +- Deletion failures do not crash the bot or block subsequent message processing. + +--- + +## Storage & Directories + +Encrypted content is organized into the following directories (configured in `config/default.toml` under `[storage.paths]`): + +| Directory | Purpose | +|-----------|---------| +| `data/media` | Image, video, and audio files (`image/*`, `video/*`, `audio/*`). | +| `data/documents` | All other file types (archives, binaries, etc.). | +| `data/text` | Plain text uploads (`text/*` MIME types). | +| `data/temp` | Temporary files during encryption and upload processing. | +| `data/logs` | Rolling log output from the bot and server. | + +**Directory creation:** Both the bot and server call `storage.ensure_dirs().await` at startup, creating missing directories automatically. + +--- + +## Rolling Log Files + +Both the bot (`crates/cgcx-bot/src/main.rs`) and the server (`crates/cgcx-server/src/main.rs`) use `tracing-appender` for daily log rotation: + +```rust +tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(log_prefix) + .max_log_files(config.logging.max_files) + .build(log_dir) +``` + +- **Rotation:** Daily. +- **Retention:** `max_files` (default: `7`). +- **Paths:** + - Bot: `data/logs/cgcx-bot.log` (or configured `logging.file_path`) + - Server: `data/logs/cgcx-server.log` +- **Format:** Plain text, ANSI colors disabled for file output. +- **Fallback:** If the rolling appender fails to initialize, the process falls back to console-only logging. + +--- + +## SQLite WAL Mode + +Every database connection is opened with: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA busy_timeout = 5000; +``` + +**Implications:** +- **WAL (Write-Ahead Logging)** allows readers to proceed without blocking on writers, which is important because the bot and server may share the same SQLite file. +- A `busy_timeout` of 5000 ms reduces "database is locked" errors under concurrent load. +- WAL produces companion files (`db.sqlite-wal`, `db.sqlite-shm`) in the same directory as the database. These are safe to leave in place during normal operation and are automatically checkpointed by SQLite. + +--- + +## Background Task Intervals + +| Task | Interval | Description | +|------|----------|-------------| +| **Punishment expiration** | 60 seconds | Bot task that queries `punishments` for expired timed bans/mutes and lifts them. | +| **Orphan cleanup** | 24 hours | Server task that runs `FilePipeline::cleanup_orphans()` to remove files belonging to deleted/blacklisted content (only if `keep_content = false`). | + +**Note:** The orphan sweeper skips its first tick on startup to avoid immediate load spikes. + +--- + +## Frontend Chunk Size Warning + +The frontend build uses Vite with its default configuration. During `npm run build`, Vite may emit warnings such as: + +``` +(!) Some chunks are larger than 500 kBs after minification. +``` + +- This is a **non-blocking** warning; the build completes successfully. +- The warning typically comes from large vendor dependencies (e.g., PDF.js, syntax highlighters). +- No custom `chunkSizeWarningLimit` is configured; the default Vite behavior is accepted. + +--- + +## HTTP Rate Limiting (Server) + +The Axum server uses `tower-governor` for per-IP rate limiting: + +| Route Group | Config Key | Default | Burst | +|-------------|-----------|---------|-------| +| General API (`/api/health`, `/api/content/...`) | `rate_limiting.requests_per_minute` | 60 | 10 | +| Password verification (`POST /api/content/:cxid/verify-password`) | `rate_limiting.password_attempts_per_minute` | 4 | 3 | + +- Exceeding the general limit returns `429 Too Many Requests`. +- The password endpoint has a separate, stricter limit to mitigate brute-force attacks. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c8ccf7..fab09c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "dompurify": "^3.0.0", + "highlight.js": "^11.11.1", + "mammoth": "^1.12.0", "marked": "^12.0.0" }, "devDependencies": { @@ -467,6 +469,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -480,6 +491,15 @@ "node": ">=0.4.0" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -500,6 +520,32 @@ "node": ">= 0.4" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -510,6 +556,12 @@ "node": ">=6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -537,6 +589,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dompurify": { "version": "3.4.5", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", @@ -546,6 +604,15 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -604,6 +671,27 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -614,6 +702,33 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -894,6 +1009,17 @@ "dev": true, "license": "MIT" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -904,6 +1030,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/marked": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", @@ -946,6 +1096,27 @@ ], "license": "MIT" }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -995,6 +1166,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1029,6 +1221,18 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1039,6 +1243,21 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/svelte": { "version": "5.55.9", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", @@ -1092,6 +1311,18 @@ "license": "0BSD", "optional": true }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", @@ -1190,6 +1421,15 @@ } } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 694b43a..239e7bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "dompurify": "^3.0.0", + "highlight.js": "^11.11.1", + "mammoth": "^1.12.0", "marked": "^12.0.0" } } diff --git a/frontend/src/components/CodeViewer.svelte b/frontend/src/components/CodeViewer.svelte new file mode 100644 index 0000000..0435447 --- /dev/null +++ b/frontend/src/components/CodeViewer.svelte @@ -0,0 +1,92 @@ + + +
+ {#if fileName || rawUrl} +
+ {#if fileName}{fileName}{/if} + {#if rawUrl}[ Raw ]{/if} +
+ {/if} + {#if loading} +

Loading code...

+ {:else} +
{text}
+ {/if} +
+ + diff --git a/frontend/src/components/DocxViewer.svelte b/frontend/src/components/DocxViewer.svelte new file mode 100644 index 0000000..09ce96a --- /dev/null +++ b/frontend/src/components/DocxViewer.svelte @@ -0,0 +1,98 @@ + + +
+
+ [ DOCX ] + Download +
+ {#if loading} +

Loading DOCX...

+ {:else if error} +

{error}

+ Download + {:else} +
{@html html}
+ {/if} +
+ + diff --git a/frontend/src/components/ImageViewer.svelte b/frontend/src/components/ImageViewer.svelte index 51e2a0d..7636af8 100644 --- a/frontend/src/components/ImageViewer.svelte +++ b/frontend/src/components/ImageViewer.svelte @@ -1,9 +1,14 @@
- {name} + {#if failed} +
[ Failed to load image ]
+ {:else} + {name} failed = true} /> + {/if}
diff --git a/frontend/src/components/MixedGallery.svelte b/frontend/src/components/MixedGallery.svelte index 671c463..8f3bef4 100644 --- a/frontend/src/components/MixedGallery.svelte +++ b/frontend/src/components/MixedGallery.svelte @@ -1,12 +1,17 @@ @@ -39,9 +48,19 @@ {:else if viewer === 'markdown'} {:else if viewer === 'text'} - + {#if detectLanguage(file.name)} + + {:else} + + {/if} + {:else if viewer === 'pdf'} + + {:else if viewer === 'docx'} + {:else if viewer === 'dangerous'} + {:else if viewer === 'sensitive'} + {:else} {/if} diff --git a/frontend/src/components/PdfViewer.svelte b/frontend/src/components/PdfViewer.svelte new file mode 100644 index 0000000..56f2f28 --- /dev/null +++ b/frontend/src/components/PdfViewer.svelte @@ -0,0 +1,23 @@ + + +
+ +
+ + diff --git a/frontend/src/components/SensitiveWarning.svelte b/frontend/src/components/SensitiveWarning.svelte new file mode 100644 index 0000000..4a28069 --- /dev/null +++ b/frontend/src/components/SensitiveWarning.svelte @@ -0,0 +1,56 @@ + + +
+
[ Sensitive Data ]
+

{file.name}

+

{file.mime} • {formatSize(file.size)}

+

+ This file may contain sensitive data. Be careful when handling it. +

+ Download +
+ + diff --git a/frontend/src/components/TextViewer.svelte b/frontend/src/components/TextViewer.svelte index 891632e..29ec5cc 100644 --- a/frontend/src/components/TextViewer.svelte +++ b/frontend/src/components/TextViewer.svelte @@ -1,5 +1,5 @@
+ {#if fileName || rawUrl} +
+ {#if fileName}{fileName}{/if} + {#if rawUrl}[ Raw ]{/if} +
+ {/if} {#if loading}

Loading text...

{:else} @@ -34,6 +40,33 @@ border: 3px solid var(--retro-border); box-shadow: 6px 6px 0px var(--retro-shadow); } + .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 2px solid var(--retro-border); + } + .label { + font-family: 'Press Start 2P', cursive; + font-size: 0.6rem; + color: var(--retro-green); + } + .raw-btn { + font-family: 'Press Start 2P', cursive; + font-size: 0.5rem; + padding: 6px 10px; + border: 2px solid var(--retro-border); + background: var(--retro-panel); + color: var(--retro-fg); + text-decoration: none; + box-shadow: 2px 2px 0px rgba(0,0,0,0.15); + } + .raw-btn:hover { + background: var(--retro-green); + color: #fff; + } pre { white-space: pre-wrap; word-break: break-word; diff --git a/frontend/src/components/VideoPlayer.svelte b/frontend/src/components/VideoPlayer.svelte index 812d5c8..9e99a11 100644 --- a/frontend/src/components/VideoPlayer.svelte +++ b/frontend/src/components/VideoPlayer.svelte @@ -4,7 +4,9 @@
- +
diff --git a/frontend/src/routes/ViewContent.svelte b/frontend/src/routes/ViewContent.svelte index b827cce..965691d 100644 --- a/frontend/src/routes/ViewContent.svelte +++ b/frontend/src/routes/ViewContent.svelte @@ -1,12 +1,17 @@ @@ -133,9 +133,19 @@ {:else if viewer === 'markdown'} {:else if viewer === 'text'} - + {#if detectLanguage(file.name)} + + {:else} + + {/if} + {:else if viewer === 'pdf'} + + {:else if viewer === 'docx'} + {:else if viewer === 'executable' || viewer === 'dangerous'} + {:else if viewer === 'sensitive'} + {:else} {/if} diff --git a/migrations/003_forward_system.sql b/migrations/003_forward_system.sql new file mode 100644 index 0000000..c0ebde0 --- /dev/null +++ b/migrations/003_forward_system.sql @@ -0,0 +1,37 @@ +CREATE TABLE forward_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creator_user_id INTEGER NOT NULL, + source_chat_id INTEGER NOT NULL, + destination_chat_id INTEGER NOT NULL, + review_group_id INTEGER NOT NULL, + forward_message TEXT NOT NULL DEFAULT '', + code TEXT NOT NULL UNIQUE, + share_mode TEXT NOT NULL DEFAULT 'b', + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_forward_code ON forward_definitions(code); +CREATE INDEX idx_forward_source ON forward_definitions(source_chat_id); + +CREATE TABLE forward_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + forward_id INTEGER NOT NULL REFERENCES forward_definitions(id), + user_id INTEGER NOT NULL, + content_id TEXT NOT NULL REFERENCES contents(id), + status TEXT NOT NULL DEFAULT 'pending', + review_message_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT, + resolver_id INTEGER +); +CREATE INDEX idx_fwd_sub_forward ON forward_submissions(forward_id); +CREATE INDEX idx_fwd_sub_user ON forward_submissions(user_id); +CREATE INDEX idx_fwd_sub_status ON forward_submissions(status); + +CREATE TABLE forward_lists ( + forward_id INTEGER NOT NULL REFERENCES forward_definitions(id), + user_id INTEGER NOT NULL, + list_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (forward_id, user_id, list_type) +); diff --git a/migrations/004_punishments.sql b/migrations/004_punishments.sql new file mode 100644 index 0000000..5fc2800 --- /dev/null +++ b/migrations/004_punishments.sql @@ -0,0 +1,15 @@ +CREATE TABLE punishments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick' + duration_seconds INTEGER, -- NULL = permanent + reason TEXT, + created_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT, + revoked_by INTEGER, + active INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id); +CREATE INDEX idx_punishments_active ON punishments(active);