Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.
This commit is contained in:
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user