Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.

This commit is contained in:
unknown
2026-05-22 21:46:06 +02:00
parent 12a0035699
commit 2129081599
32 changed files with 3426 additions and 106 deletions

View File

@@ -20,6 +20,7 @@ teloxide = { version = "0.13", features = ["macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"

File diff suppressed because it is too large Load Diff

View File

@@ -100,6 +100,24 @@ pub struct RateLimitConfig {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
pub level: String,
#[serde(default = "default_file_enabled")]
pub file_enabled: bool,
#[serde(default = "default_file_path")]
pub file_path: String,
#[serde(default = "default_max_files")]
pub max_files: usize,
}
fn default_file_enabled() -> bool {
true
}
fn default_file_path() -> String {
"data/logs/cgcx-server.log".to_string()
}
fn default_max_files() -> usize {
7
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -7,9 +7,15 @@ pub const RENDER_DOCUMENT: u32 = 1 << 5;
pub const RENDER_EXECUTABLE: u32 = 1 << 6;
pub const RENDER_DANGEROUS: u32 = 1 << 7;
pub const RENDER_NO_INLINE: u32 = 1 << 8;
pub const RENDER_SENSITIVE: u32 = 1 << 9;
const DANGEROUS_EXTENSIONS: &[&str] = &[
"exe", "scr", "bat", "cmd", "sh", "dll", "so", "dylib", "jar", "msi", "com", "app", "apk",
"ps1", "py", "pyw", "vbs", "js", "html", "htm",
];
const SENSITIVE_EXTENSIONS: &[&str] = &[
"db", "sqlite", "sqlite3", "sqlitedb", "mdf", "mdb", "accdb", "dump", "sql", "backup", "bak",
];
const DANGEROUS_MIME_TYPES: &[&str] = &[
@@ -18,6 +24,11 @@ const DANGEROUS_MIME_TYPES: &[&str] = &[
"text/css",
"application/javascript",
"application/ecmascript",
"application/x-python",
"text/x-python",
"application/x-powershell",
"application/x-shellscript",
"text/x-shellscript",
];
pub fn detect_mime_type(data: &[u8], file_name: &str) -> String {
@@ -61,6 +72,10 @@ pub fn compute_render_flags(mime_type: &str, file_name: &str, data: &[u8]) -> u3
flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE;
}
if SENSITIVE_EXTENSIONS.contains(&ext.as_str()) {
flags |= RENDER_SENSITIVE | RENDER_NO_INLINE;
}
if let Some(kind) = infer::get(data) {
let mime = kind.mime_type();
if mime == "application/x-executable"

View File

@@ -84,3 +84,45 @@ pub struct AdminAction {
pub action: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ForwardDefinition {
pub id: i64,
pub creator_user_id: i64,
pub source_chat_id: i64,
pub destination_chat_id: i64,
pub review_group_id: i64,
pub forward_message: String,
pub code: String,
pub share_mode: String,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ForwardSubmission {
pub id: i64,
pub forward_id: i64,
pub user_id: i64,
pub content_id: ContentId,
pub status: String,
pub review_message_id: Option<i32>,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub resolver_id: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct Punishment {
pub id: i64,
pub chat_id: i64,
pub target_user_id: i64,
pub action_type: String,
pub duration_seconds: Option<i64>,
pub reason: Option<String>,
pub created_by: i64,
pub created_at: String,
pub revoked_at: Option<String>,
pub revoked_by: Option<i64>,
pub active: bool,
}

View File

@@ -47,6 +47,8 @@ impl Database {
let migrations = rusqlite_migration::Migrations::new(vec![
rusqlite_migration::M::up(include_str!("../../../migrations/001_init.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/002_indexes.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/003_forward_system.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/004_punishments.sql")),
]);
migrations.to_latest(&mut *conn)
.map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?;

View File

@@ -1,5 +1,5 @@
use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, Report, ReportStatus, Result, CgcxError, User};
use rusqlite::{params, OptionalExtension};
use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, ForwardDefinition, ForwardSubmission, Punishment, Report, ReportStatus, Result, CgcxError, User};
use rusqlite::{params, OptionalExtension, Connection};
use std::sync::Arc;
use tokio::sync::Mutex;
@@ -197,6 +197,15 @@ impl ContentRepo {
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn update_password_hash(&self, id: &ContentId, password_hash: Option<&str>) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE contents SET password_hash = ?1 WHERE id = ?2",
params![password_hash, id.as_str()],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
}
pub struct ContentFileRepo {
@@ -387,3 +396,304 @@ impl AdminActionRepo {
Ok(conn.last_insert_rowid())
}
}
pub struct ForwardRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ForwardRepo {
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, creator_user_id: i64, source_chat_id: i64, destination_chat_id: i64, review_group_id: i64, forward_message: &str, code: &str, share_mode: &str) -> Result<i64> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO forward_definitions (creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
pub async fn get_by_code(&self, code: &str) -> Result<Option<ForwardDefinition>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
FROM forward_definitions WHERE code = ?1",
params![code],
|row| {
Ok(ForwardDefinition {
id: row.get(0)?,
creator_user_id: row.get(1)?,
source_chat_id: row.get(2)?,
destination_chat_id: row.get(3)?,
review_group_id: row.get(4)?,
forward_message: row.get(5)?,
code: row.get(6)?,
share_mode: row.get(7)?,
revoked_at: row.get(8)?,
created_at: row.get(9)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn list_by_source_chat(&self, source_chat_id: i64, limit: usize, offset: usize) -> Result<Vec<ForwardDefinition>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
FROM forward_definitions WHERE source_chat_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![source_chat_id, limit as i64, offset as i64], |row| {
Ok(ForwardDefinition {
id: row.get(0)?,
creator_user_id: row.get(1)?,
source_chat_id: row.get(2)?,
destination_chat_id: row.get(3)?,
review_group_id: row.get(4)?,
forward_message: row.get(5)?,
code: row.get(6)?,
share_mode: row.get(7)?,
revoked_at: row.get(8)?,
created_at: row.get(9)?,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
pub async fn revoke(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE forward_definitions SET revoked_at = datetime('now') WHERE id = ?1",
params![id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn is_allowed(&self, forward_id: i64, user_id: i64) -> Result<bool> {
let conn = self.conn.lock().await;
let def: Option<ForwardDefinition> = conn.query_row(
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
FROM forward_definitions WHERE id = ?1",
params![forward_id],
|row| {
Ok(ForwardDefinition {
id: row.get(0)?,
creator_user_id: row.get(1)?,
source_chat_id: row.get(2)?,
destination_chat_id: row.get(3)?,
review_group_id: row.get(4)?,
forward_message: row.get(5)?,
code: row.get(6)?,
share_mode: row.get(7)?,
revoked_at: row.get(8)?,
created_at: row.get(9)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
if let Some(def) = def {
if def.creator_user_id == user_id {
return Ok(true);
}
let list_entry: Option<String> = conn.query_row(
"SELECT list_type FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2",
params![forward_id, user_id],
|row| row.get(0),
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
match def.share_mode.as_str() {
"w" => Ok(list_entry.map(|t| t == "allow").unwrap_or(false)),
_ => Ok(list_entry.map(|t| t != "block").unwrap_or(true)),
}
} else {
Ok(false)
}
}
pub async fn insert_submission(&self, forward_id: i64, user_id: i64, content_id: &str) -> Result<i64> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO forward_submissions (forward_id, user_id, content_id) VALUES (?1, ?2, ?3)",
params![forward_id, user_id, content_id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
pub async fn get_submission(&self, id: i64) -> Result<Option<ForwardSubmission>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, forward_id, user_id, content_id, status, review_message_id, created_at, resolved_at, resolver_id
FROM forward_submissions WHERE id = ?1",
params![id],
|row| {
Ok(ForwardSubmission {
id: row.get(0)?,
forward_id: row.get(1)?,
user_id: row.get(2)?,
content_id: ContentId::new_unchecked(row.get(3)?),
status: row.get(4)?,
review_message_id: row.get(5)?,
created_at: row.get(6)?,
resolved_at: row.get(7)?,
resolver_id: row.get(8)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn set_review_message_id(&self, id: i64, message_id: i32) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE forward_submissions SET review_message_id = ?1 WHERE id = ?2",
params![message_id, id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn update_status(&self, id: i64, status: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE forward_submissions SET status = ?1, resolved_at = datetime('now') WHERE id = ?2",
params![status, id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn get_definition(&self, id: i64) -> Result<Option<ForwardDefinition>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
FROM forward_definitions WHERE id = ?1",
params![id],
|row| {
Ok(ForwardDefinition {
id: row.get(0)?,
creator_user_id: row.get(1)?,
source_chat_id: row.get(2)?,
destination_chat_id: row.get(3)?,
review_group_id: row.get(4)?,
forward_message: row.get(5)?,
code: row.get(6)?,
share_mode: row.get(7)?,
revoked_at: row.get(8)?,
created_at: row.get(9)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn add_to_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO forward_lists (forward_id, user_id, list_type) VALUES (?1, ?2, ?3) ON CONFLICT DO NOTHING",
params![forward_id, user_id, list_type],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn remove_from_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"DELETE FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2 AND list_type = ?3",
params![forward_id, user_id, list_type],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
}
pub struct PunishmentRepo {
conn: Arc<Mutex<Connection>>,
}
impl PunishmentRepo {
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
Self { conn }
}
pub async fn insert(
&self,
chat_id: i64,
target_user_id: i64,
action_type: &str,
duration_seconds: Option<i64>,
reason: Option<&str>,
created_by: i64,
) -> Result<i64> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO punishments (chat_id, target_user_id, action_type, duration_seconds, reason, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![chat_id, target_user_id, action_type, duration_seconds, reason, created_by],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
pub async fn get_active_for_chat_target(&self, chat_id: i64, target_user_id: i64, action_type: &str) -> Result<Vec<Punishment>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE chat_id = ?1 AND target_user_id = ?2 AND action_type = ?3 AND active = 1"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![chat_id, target_user_id, action_type], |row| {
Ok(Punishment {
id: row.get(0)?,
chat_id: row.get(1)?,
target_user_id: row.get(2)?,
action_type: row.get(3)?,
duration_seconds: row.get(4)?,
reason: row.get(5)?,
created_by: row.get(6)?,
created_at: row.get(7)?,
revoked_at: row.get(8)?,
revoked_by: row.get(9)?,
active: row.get::<_, i64>(10)? != 0,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut results = vec![];
for r in rows {
results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(results)
}
pub async fn revoke(&self, id: i64, revoked_by: i64) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE punishments SET active = 0, revoked_at = datetime('now'), revoked_by = ?1 WHERE id = ?2",
params![revoked_by, id],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn list_expired(&self) -> Result<Vec<Punishment>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE active = 1 AND duration_seconds IS NOT NULL AND datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map([], |row| {
Ok(Punishment {
id: row.get(0)?,
chat_id: row.get(1)?,
target_user_id: row.get(2)?,
action_type: row.get(3)?,
duration_seconds: row.get(4)?,
reason: row.get(5)?,
created_by: row.get(6)?,
created_at: row.get(7)?,
revoked_at: row.get(8)?,
revoked_by: row.get(9)?,
active: row.get::<_, i64>(10)? != 0,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut results = vec![];
for r in rows {
results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(results)
}
}

View File

@@ -21,6 +21,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }

View File

@@ -26,6 +26,7 @@ use tower_http::{
trace::TraceLayer,
};
use tracing::{info, warn};
use tracing_subscriber::prelude::*;
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal;
#[derive(Clone)]
@@ -68,9 +69,42 @@ struct VerifyPasswordRequest {
password: String,
}
fn deserialize_download_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DownloadBoolVisitor;
impl<'de> serde::de::Visitor<'de> for DownloadBoolVisitor {
type Value = bool;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a boolean or string representing a boolean")
}
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E> {
Ok(v)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> {
Ok(matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
self.visit_str(&v)
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> {
Ok(v == 1)
}
}
deserializer.deserialize_any(DownloadBoolVisitor)
}
#[derive(Deserialize)]
struct FileQuery {
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_download_bool")]
download: bool,
#[serde(rename = "sc", default)]
sc: Option<String>,
@@ -87,6 +121,11 @@ struct ByteRange {
end: Option<u64>,
}
enum AuthSource {
Cookie,
QueryParam,
}
struct AppError(CgcxError);
impl From<CgcxError> for AppError {
@@ -119,7 +158,57 @@ 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 env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&config.logging.level));
let console_layer = tracing_subscriber::fmt::layer();
let _file_guard = if config.logging.file_enabled {
let log_path = std::path::Path::new(&config.logging.file_path);
let log_dir = log_path.parent()
.and_then(|p| p.to_str())
.unwrap_or("data/logs");
let log_prefix = log_path.file_name()
.and_then(|f| f.to_str())
.unwrap_or("cgcx-server.log");
std::fs::create_dir_all(log_dir).ok();
match tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(log_prefix)
.max_log_files(config.logging.max_files)
.build(log_dir)
{
Ok(file_appender) => {
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false);
tracing_subscriber::registry()
.with(env_filter)
.with(console_layer)
.with(file_layer)
.init();
Some(guard)
}
Err(e) => {
tracing::warn!("Failed to create rolling file appender at {}: {}. Falling back to console only.", log_dir, e);
tracing_subscriber::registry()
.with(env_filter)
.with(console_layer)
.init();
None
}
}
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(console_layer)
.init();
None
};
// Log panics so we can diagnose 500s that CatchPanicLayer swallows.
std::panic::set_hook(Box::new(|info| {
@@ -134,9 +223,6 @@ async fn main() -> cgcx_core::Result<()> {
tracing::error!("PANIC at {}: {}", location, msg);
}));
let config = Arc::new(Config::load()?);
config.validate()?;
let db_path = std::path::PathBuf::from(&config.database_path);
if let Some(parent) = db_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
@@ -236,6 +322,7 @@ async fn main() -> cgcx_core::Result<()> {
.route("/api/health", get(health))
.route("/api/content/:cxid", get(get_metadata))
.route("/api/content/:cxid/file/:file_idx", get(serve_file))
.route("/api/content/:cxid/file/:file_idx/raw", get(serve_raw_file))
.merge(password_route)
.nest_service("/assets", static_service)
.fallback(fallback)
@@ -299,7 +386,7 @@ async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Respons
let headers = response.headers_mut();
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"),
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; object-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"),
);
headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
@@ -328,19 +415,19 @@ fn password_from_request(
cxid: &str,
password_hash: Option<&str>,
cookie_secret: &[u8],
) -> bool {
) -> Option<AuthSource> {
if let Some(sc) = query_sc {
if let Some(hash) = password_hash {
use argon2::{Argon2, PasswordHash, PasswordVerifier};
if let Ok(parsed_hash) = PasswordHash::new(hash) {
if Argon2::default().verify_password(sc.as_bytes(), &parsed_hash).is_ok() {
return true;
return Some(AuthSource::QueryParam);
}
}
}
}
headers
if headers
.get_all(header::COOKIE)
.iter()
.any(|v| {
@@ -351,6 +438,31 @@ fn password_from_request(
})
}).unwrap_or(false)
})
{
return Some(AuthSource::Cookie);
}
None
}
fn add_auth_cookie(
response: &mut Response,
auth_source: &Option<AuthSource>,
cxid: &str,
cookie_secret: &[u8],
) -> AppResult<()> {
if let Some(AuthSource::QueryParam) = auth_source {
let cookie_value = make_cookie_value(cxid, cookie_secret);
let cookie = format!(
"cgcx_pw={}; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/",
cookie_value
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| CgcxError::Storage("invalid cookie header".into()))?,
);
}
Ok(())
}
async fn get_metadata(
@@ -378,12 +490,17 @@ async fn get_metadata(
}
}
if content.password_hash.is_some() {
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid);
return Err(CgcxError::Unauthorized.into());
let auth_source = if content.password_hash.is_some() {
match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
Some(source) => Some(source),
None => {
tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid);
return Err(CgcxError::Unauthorized.into());
}
}
}
} else {
None
};
let file_repo = ContentFileRepo::new(state.db.conn());
let files = file_repo.list_by_content(&content_id).await?;
@@ -403,11 +520,13 @@ async fn get_metadata(
allow_download: content.allow_download,
created_at: content.created_at.to_rfc3339(),
}).map_err(|_| CgcxError::BadRequest("json serialization".into()))?;
Ok(Response::builder()
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
Ok(response)
}
async fn verify_password(
@@ -451,6 +570,22 @@ async fn verify_password(
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
}
fn client_ip_from_headers(headers: &HeaderMap) -> String {
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(s) = xff.to_str() {
if let Some(ip) = s.split(',').next() {
return ip.trim().to_string();
}
}
}
if let Some(xri) = headers.get("x-real-ip") {
if let Ok(s) = xri.to_str() {
return s.trim().to_string();
}
}
"unknown".to_string()
}
async fn serve_file(
State(state): State<AppState>,
Path((cxid, file_idx)): Path<(String, u32)>,
@@ -476,15 +611,22 @@ async fn serve_file(
}
}
if content.password_hash.is_some() {
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
tracing::warn!("serve_file returning Unauthorized for cxid={}", cxid);
return Err(CgcxError::Unauthorized.into());
let auth_source = if content.password_hash.is_some() {
match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
Some(source) => Some(source),
None => {
let ip = client_ip_from_headers(&headers);
tracing::warn!("serve_file returning Unauthorized for cxid={} file_idx={} ip={}", cxid, file_idx, ip);
return Err(CgcxError::Unauthorized.into());
}
}
}
} else {
None
};
if query.download && !content.allow_download {
tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={}", cxid);
let ip = client_ip_from_headers(&headers);
tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={} file_idx={} ip={}", cxid, file_idx, ip);
return Err(CgcxError::Forbidden.into());
}
@@ -502,26 +644,30 @@ async fn serve_file(
} else {
format!("inline; filename=\"{}\"", sanitized_name)
};
return Ok(Response::builder()
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ETAG, etag)
.header(header::CONTENT_LENGTH, "0")
.header(header::CACHE_CONTROL, "private, no-store, max-age=0")
.header(header::CACHE_CONTROL, if content.password_hash.is_some() { "private, no-store, max-age=0" } else { "private, max-age=60" })
.body(Body::empty())
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
return Ok(response);
}
// Path traversal validation
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
.map_err(|e| {
tracing::error!("canonicalize failed for {:?}: {}", file.stored_path, e);
let ip = client_ip_from_headers(&headers);
tracing::error!("canonicalize failed for {:?}: {} | ip={} cxid={} file_idx={}", file.stored_path, e, ip, cxid, file_idx);
CgcxError::Storage("invalid stored path".into())
})?;
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
tracing::error!("Path traversal blocked: {:?}", canonical_path);
tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid);
let ip = client_ip_from_headers(&headers);
tracing::error!("Path traversal blocked: {:?} | ip={} cxid={} file_idx={}", canonical_path, ip, cxid, file_idx);
tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={} file_idx={} ip={}", cxid, file_idx, ip);
return Err(CgcxError::Forbidden.into());
}
@@ -530,11 +676,13 @@ async fn serve_file(
// If-None-Match check (skip increment)
if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) {
return Ok(Response::builder()
let mut response = Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag.clone())
.body(Body::empty())
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
return Ok(response);
}
}
@@ -573,10 +721,12 @@ async fn serve_file(
let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await;
}
repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?;
return Ok(Response::builder()
let mut response = Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
return Ok(response);
}
}
}
@@ -636,7 +786,140 @@ async fn serve_file(
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let body = Body::from_stream(body_stream);
Ok(response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
let mut response = response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
Ok(response)
}
async fn serve_raw_file(
State(state): State<AppState>,
Path((cxid, file_idx)): Path<(String, u32)>,
Query(query): Query<ScQuery>,
headers: HeaderMap,
) -> AppResult<impl IntoResponse> {
tracing::info!("serve_raw_file: cxid={} file_idx={}", cxid, file_idx);
let content_id = ContentId::try_from(cxid.as_str())?;
let repo = ContentRepo::new(state.db.conn());
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
tracing::warn!("serve_raw_file returning NotFound for cxid={}", cxid);
return Err(CgcxError::NotFound.into());
}
if let Some(max) = content.max_views {
if content.view_count >= max {
return Ok(Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
}
}
let auth_source = if content.password_hash.is_some() {
match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
Some(source) => Some(source),
None => {
let ip = client_ip_from_headers(&headers);
tracing::warn!("serve_raw_file returning Unauthorized for cxid={} file_idx={} ip={}", cxid, file_idx, ip);
return Err(CgcxError::Unauthorized.into());
}
}
} else {
None
};
let file_repo = ContentFileRepo::new(state.db.conn());
let files = file_repo.list_by_content(&content_id).await?;
let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?;
// Handle zero-size files early
if file.size_bytes == 0 {
let sanitized_name = sanitize_content_disposition(&file.original_name);
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", sanitized_name))
.header(header::CONTENT_LENGTH, "0")
.body(Body::empty())
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
return Ok(response);
}
// Path traversal validation
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
.map_err(|e| {
let ip = client_ip_from_headers(&headers);
tracing::error!("canonicalize failed for {:?}: {} | ip={} cxid={} file_idx={}", file.stored_path, e, ip, cxid, file_idx);
CgcxError::Storage("invalid stored path".into())
})?;
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
let ip = client_ip_from_headers(&headers);
tracing::error!("Path traversal blocked: {:?} | ip={} cxid={} file_idx={}", canonical_path, ip, cxid, file_idx);
tracing::warn!("serve_raw_file returning Forbidden (path traversal) for cxid={} file_idx={} ip={}", cxid, file_idx, ip);
return Err(CgcxError::Forbidden.into());
}
// Decrypt entire file into memory
let mut f = tokio::fs::File::open(&file.stored_path).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
let mut header_buf = [0u8; 24];
f.read_exact(&mut header_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
let content_key = unwrap_content_key(&file.encrypted_key_wrapped, &state.master_key)?;
let header = sodiumoxide::crypto::secretstream::xchacha20poly1305::Header::from_slice(&header_buf)
.ok_or_else(|| CgcxError::Crypto("invalid header".into()))?;
let mut decrypt_stream = DecryptStream::new(&content_key, &header)?;
let mut plaintext = Vec::with_capacity(file.size_bytes as usize);
let mut len_buf = [0u8; 4];
let mut saw_final = false;
loop {
if f.read_exact(&mut len_buf).await.is_err() {
break; // EOF at message boundary
}
let msg_len = u32::from_le_bytes(len_buf) as usize;
if msg_len > 50_000_000 {
return Err(AppError(CgcxError::Crypto("message length exceeds sanity bound".into())));
}
let mut msg_buf = vec![0u8; msg_len];
f.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
match decrypt_stream.pull(&msg_buf) {
Ok((chunk, tag)) => {
plaintext.extend_from_slice(&chunk);
if tag == TagFinal {
saw_final = true;
break;
}
}
Err(e) => {
return Err(AppError(e));
}
}
}
if !saw_final {
return Err(AppError(CgcxError::Crypto("stream ended without Final tag".into())));
}
let computed_hash = decrypt_stream.finalize().to_vec();
if computed_hash != file.encrypted_hash {
tracing::error!(target: "critical", "BLAKE3 integrity mismatch for raw file {:?}: expected {} got {}", file.stored_path, hex::encode(&file.encrypted_hash), hex::encode(&computed_hash));
return Err(AppError(CgcxError::Crypto("BLAKE3 integrity mismatch".into())));
}
let text = String::from_utf8_lossy(&plaintext);
let sanitized_name = sanitize_content_disposition(&file.original_name);
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", sanitized_name))
.body(Body::from(text.into_owned()))
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?;
add_auth_cookie(&mut response, &auth_source, &cxid, &state.cookie_secret)?;
Ok(response)
}
async fn stream_decrypted_file(
@@ -644,7 +927,7 @@ async fn stream_decrypted_file(
master_key: Arc<MasterKey>,
wrapped_key: Vec<u8>,
tx: tokio::sync::mpsc::Sender<Result<Vec<u8>, std::io::Error>>,
_range: Option<ByteRange>,
range: Option<ByteRange>,
_file_size: u64,
expected_hash: Vec<u8>,
) -> cgcx_core::Result<()> {
@@ -658,6 +941,92 @@ async fn stream_decrypted_file(
let mut decrypt_stream = DecryptStream::new(&content_key, &header)?;
let mut len_buf = [0u8; 4];
if let Some(ref r) = range {
let range_start = r.start;
let range_len = r.end.map(|e| e - r.start + 1);
let mut skipped_plaintext: u64 = 0;
let mut sent: u64 = 0;
loop {
if file.read_exact(&mut len_buf).await.is_err() {
break; // EOF at message boundary
}
let ciphertext_len = u32::from_le_bytes(len_buf) as usize;
if ciphertext_len > 50_000_000 {
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "message too large"))).await;
return Err(CgcxError::Crypto("message length exceeds sanity bound".into()));
}
let plaintext_len = ciphertext_len.saturating_sub(16) as u64;
if skipped_plaintext + plaintext_len <= range_start {
// Advance the decrypt stream state by reading and decrypting the
// skipped chunk, then discarding the plaintext. XChaCha20-Poly1305
// secretstream is stateful and must be processed sequentially.
let mut skip_buf = vec![0u8; ciphertext_len];
file.read_exact(&mut skip_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
match decrypt_stream.pull(&skip_buf) {
Ok((_, tag)) => {
if tag == TagFinal {
break;
}
}
Err(e) => {
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await;
return Err(e);
}
}
skipped_plaintext += plaintext_len;
continue;
}
let trim_start = if skipped_plaintext < range_start {
(range_start - skipped_plaintext) as usize
} else {
0
};
let mut msg_buf = vec![0u8; ciphertext_len];
file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
match decrypt_stream.pull(&msg_buf) {
Ok((plaintext, tag)) => {
let start = trim_start.min(plaintext.len());
let mut slice = &plaintext[start..];
if let Some(max_total) = range_len {
let remaining = (max_total - sent) as usize;
if slice.len() > remaining {
slice = &slice[..remaining];
}
}
if !slice.is_empty() {
if tx.send(Ok(slice.to_vec())).await.is_err() {
return Ok(()); // client disconnected
}
sent += slice.len() as u64;
}
if let Some(max_total) = range_len {
if sent >= max_total {
return Ok(());
}
}
if tag == TagFinal {
break;
}
}
Err(e) => {
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await;
return Err(e);
}
}
skipped_plaintext += plaintext_len;
}
return Ok(());
}
// Full-file streaming
let mut saw_final = false;
loop {
if file.read_exact(&mut len_buf).await.is_err() {