Initial commit

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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