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

82
Cargo.lock generated
View File

@@ -345,6 +345,7 @@ dependencies = [
"teloxide",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
@@ -466,6 +467,7 @@ dependencies = [
"tower-http",
"tower_governor",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
@@ -634,6 +636,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -705,6 +716,15 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1715,6 +1735,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1919,6 +1945,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2524,6 +2556,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "1.0.109"
@@ -2732,6 +2770,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -2936,6 +3005,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"

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) {
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);
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() {

134
docs/API.md Normal file
View File

@@ -0,0 +1,134 @@
# HTTP API Reference
## Endpoints
### GET /api/health
- **Description:** Health check endpoint.
- **Auth:** None
- **Query params:** None
- **Response:** `{"status":"ok"}`
---
### GET /api/content/:cxid
- **Description:** Get content metadata (files, view limits, password status, etc.).
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- **Response formats:**
- `200 OK` — JSON metadata object:
```json
{
"cxid": "string",
"files": [
{
"idx": 0,
"name": "string",
"mime": "string",
"size": 12345,
"render_flags": 0
}
],
"has_password": true,
"max_views": 10,
"current_views": 3,
"allow_download": true,
"created_at": "2024-01-01T00:00:00+00:00"
}
```
- `401 Unauthorized` — Password required but missing or invalid.
- `404 Not Found` — Content does not exist or has been deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- **Notes:**
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response (`Max-Age=3600; SameSite=Strict; HttpOnly; Path=/`).
---
### GET /api/content/:cxid/file/:file_idx
- **Description:** Serve a decrypted file. Supports HTTP Range requests for video/audio streaming. Returns the file with `Content-Disposition: inline` by default, or `attachment` when downloading.
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- `file_idx` — Zero-based file index within the content bundle.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- `download` (optional) — If truthy (`1`, `true`, `yes`), requests a download (`Content-Disposition: attachment`). Ignored if `allow_download` is `false` for the content.
- **Response formats:**
- `200 OK` — File stream with appropriate `Content-Type`.
- `206 Partial Content` — Byte-range response (if `Range` header is present and valid).
- `401 Unauthorized` — Password required but missing or invalid.
- `403 Forbidden` — Download requested but not allowed, or path traversal blocked.
- `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- `416 Range Not Satisfiable` — Invalid `Range` header.
- **Notes:**
- The server increments the view counter on successful full-file responses. Range requests and `If-None-Match` (ETag) matches do **not** increment the counter.
- If the incremented view count reaches `max_views`, the server may delete content files (depending on `keep_content` config) and mark the content as `Deleted`, returning `410 Gone`.
- `Accept-Ranges: bytes` is included for `video/*` and `audio/*` MIME types.
- Cache-Control is `private, max-age=60` for unprotected content and `private, no-store, max-age=0` for password-protected content.
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response.
---
### GET /api/content/:cxid/file/:file_idx/raw
- **Description:** Serve the fully decrypted file as raw plain text (`text/plain; charset=utf-8`). The entire file is decrypted into memory before being returned. No Range support.
- **Auth:**
- None if the content has no password.
- Password required if the content has a password. Provide via query param `sc` **or** cookie `cgcx_pw`.
- **Path params:**
- `cxid` — Content ID string.
- `file_idx` — Zero-based file index within the content bundle.
- **Query params:**
- `sc` (optional) — Password as a query string parameter.
- **Response formats:**
- `200 OK` — Plain text body.
- `401 Unauthorized` — Password required but missing or invalid.
- `403 Forbidden` — Path traversal blocked.
- `404 Not Found` — Content or file index does not exist, or content deleted/blacklisted.
- `410 Gone` — Content has reached its maximum view count.
- **Notes:**
- The server performs BLAKE3 integrity verification after full decryption.
- If authentication succeeds via the `sc` query parameter, the server sets an HMAC-signed `cgcx_pw` cookie on the response.
---
### POST /api/content/:cxid/verify-password
- **Description:** Explicitly verify a password for password-protected content and receive an authentication cookie.
- **Auth:** None (this is the endpoint used to *obtain* auth).
- **Path params:**
- `cxid` — Content ID string.
- **Body:** JSON object:
```json
{
"password": "string"
}
```
- **Response formats:**
- `204 No Content` — Password is correct. The response includes a `Set-Cookie` header with `cgcx_pw`.
- `401 Unauthorized` — Password is incorrect.
- `404 Not Found` — Content does not exist.
- **Notes:**
- If the content has no password, the endpoint returns `204 No Content` without setting a cookie.
- This endpoint has a separate, stricter rate limit than the general API.
---
## General Behavior
### CORS
The server allows cross-origin requests from its configured `base_url` and common local development origins (`http://127.0.0.1:5173`, `http://localhost:5173`, `http://127.0.0.1:8090`, `http://localhost:8090`).
### Rate Limiting
- General API routes (`/api/health`, `/api/content/...`) share a per-IP rate limit configured by `requests_per_minute` and `burst`.
- `POST /api/content/:cxid/verify-password` has its own rate limit with a burst of 3 and a separate `password_attempts_per_minute` setting.
### Fallback / Static Assets
- `/assets/*` — Serves static files from `frontend/dist/assets`.
- All other non-`/api` paths — Serves `frontend/dist/index.html` (SPA fallback).
- `/api/*` paths with no matching route — Return `404 Not Found` JSON.

194
docs/AUTH_FLOW.md Normal file
View File

@@ -0,0 +1,194 @@
# Authentication Flow
cg.cx uses a simple password-and-cookie authentication model. There is no user account system; access is granted per-content-item by knowing its password.
---
## AuthSource Enum
The server tracks *how* a request was authenticated using the `AuthSource` enum:
```rust
enum AuthSource {
Cookie, // Request presented a valid cgcx_pw cookie
QueryParam, // Request presented a correct ?sc=PASSWORD query param
}
```
This distinction matters because the server only issues a **new** cookie when auth succeeds via `QueryParam`. Cookie-based auth does not re-issue the cookie (the browser already has it).
---
## How Direct Links with `?sc=PASSWORD` Work
1. A client requests a protected endpoint (e.g., `GET /api/content/:cxid` or `GET /api/content/:cxid/file/:file_idx`) with the password in the query string:
```
GET /api/content/abc123?sc=MySecretPassword
```
2. The handler calls `password_from_request(...)`.
3. Inside `password_from_request`, the `sc` query parameter is checked **first**:
- If `sc` is present and the content has a `password_hash`, the server verifies the supplied password against the stored Argon2 hash.
- If verification succeeds, `password_from_request` returns `Some(AuthSource::QueryParam)`.
4. The handler proceeds with the request.
5. On the response path, the handler calls `add_auth_cookie(...)`.
6. Because the auth source is `QueryParam`, `add_auth_cookie` generates a new HMAC-signed `cgcx_pw` cookie and attaches it via a `Set-Cookie` header.
7. The clients browser stores the cookie. Subsequent requests to the same domain automatically include it, so the `?sc=...` parameter is no longer needed.
---
## How the Server Validates Passwords (Argon2)
Password verification happens in two places: `password_from_request` and the `verify_password` endpoint.
### Stored Hash Format
Password hashes are stored in the database as Argon2id hashes in the standard PHC string format (e.g., `$argon2id$v=19$m=65536,t=3,p=4$...`).
### Verification
```rust
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(&hash)?;
let valid = Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
```
- Uses the default Argon2 parameters.
- If parsing the stored hash fails, verification is treated as failed.
- If verification fails, the server returns `401 Unauthorized`.
---
## How Cookies Are Set After Successful Query-Param Auth
After a successful `?sc=...` authentication, the response includes:
```http
Set-Cookie: cgcx_pw=<value>; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/
```
- **Name:** `cgcx_pw`
- **Max-Age:** 3600 seconds (1 hour)
- **SameSite:** `Strict`
- **HttpOnly:** `true` (not accessible to JavaScript)
- **Path:** `/`
The cookie value is generated by `make_cookie_value(cxid, cookie_secret)` (see [Cookie Format](#cookie-format-hmac-signed) below).
The same cookie is also set by the `POST /api/content/:cxid/verify-password` endpoint after a successful JSON password verification.
---
## How Subsequent Requests Use the Cookie
1. The browser automatically sends the `cgcx_pw` cookie on every request to the same origin.
2. When `password_from_request` is called:
- It first checks for `sc` query param auth.
- If that fails or is absent, it scans **all** `Cookie` headers (there may be multiple).
- For each cookie header, it splits on `;` and looks for a part starting with `cgcx_pw=`.
- When found, it calls `verify_cookie(cxid, &part[8..], cookie_secret)`.
- If `verify_cookie` returns `true`, `password_from_request` returns `Some(AuthSource::Cookie)`.
3. Because the auth source is `Cookie`, `add_auth_cookie` does **not** add a new `Set-Cookie` header.
---
## Cookie Format (HMAC-Signed)
The cookie value is a Base64-encoded string of the form:
```
base64(cxid + ":" + hmac_sha256(cxid, secret))
```
### Building the Cookie (`make_cookie_value`)
```rust
fn make_cookie_value(cxid: &str, secret: &[u8]) -> String {
let mac = hmac_cookie(cxid, secret); // HMAC-SHA256 of cxid
let mut raw = Vec::with_capacity(cxid.len() + 1 + mac.len());
raw.extend_from_slice(cxid.as_bytes());
raw.push(b':');
raw.extend_from_slice(&mac);
base64::engine::general_purpose::STANDARD.encode(&raw)
}
```
### Verifying the Cookie (`verify_cookie`)
```rust
fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool {
let decoded = base64_decode(cookie_value)?;
let mut parts = decoded.splitn(2, |&b| b == b':');
let decoded_cxid = std::str::from_utf8(parts.next()?)?;
let mac_bytes = parts.next()?;
if decoded_cxid != cxid {
return false;
}
let expected = hmac_cookie(cxid, secret);
if mac_bytes.len() != expected.len() {
return false;
}
// Constant-time comparison to prevent timing attacks
mac_bytes.ct_eq(&expected).into()
}
```
### Cookie Secret
The HMAC key (`cookie_secret`) is derived from the master encryption key at startup:
```rust
let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec();
```
This means:
- The cookie secret is deterministic for a given master key.
- Cookies are bound to a specific server instance (or cluster sharing the same master key).
- Changing the master key invalidates all existing auth cookies.
### Security Properties
- **Binding:** The HMAC includes the `cxid`, so a cookie for one piece of content cannot be replayed against another.
- **Tamper resistance:** Without the `cookie_secret`, an attacker cannot forge a valid `cgcx_pw` cookie.
- **Timing safety:** Verification uses `subtle::ConstantTimeEq` to avoid leaking information via timing side channels.
---
## Summary Flow Diagram
```
Client Request
┌─────────────────────────────────────────┐
│ Does the content have a password? │
└─────────────────────────────────────────┘
├─ No ──► Proceed (no auth needed)
└─ Yes
┌─────────────────────────────────────────┐
│ Is ?sc=PASSWORD present and correct? │
│ └─ Argon2 verify against stored hash │
└─────────────────────────────────────────┘
├─ Yes ──► AuthSource::QueryParam
│ └─ Set cgcx_pw cookie on response
└─ No
┌─────────────────────────────────────────┐
│ Is cgcx_pw cookie present and valid? │
│ └─ HMAC-SHA256 verify against secret │
└─────────────────────────────────────────┘
├─ Yes ──► AuthSource::Cookie
│ └─ Proceed without setting new cookie
└─ No ──► 401 Unauthorized
```

106
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,106 @@
# Bot Commands
This document lists all commands and callback actions implemented in `crates/cgcx-bot/src/main.rs`.
---
## Admin Commands (Group-only)
All admin commands require the caller to be an **administrator or owner** of the group.
| Command | Args | Description |
|---------|------|-------------|
| `/reload` | none | Reload moderation lists from disk. |
| `/blacklist_uid` | `<ID>` | Blacklist a user by Telegram ID globally and set their role to `banned`. |
| `/whitelist_uid` | `<ID>` | Remove a user from the global blacklist and restore their role to `user`. |
| `/help` | none | Show the admin help message listing all admin commands. |
| `/get_id` | none | Get the current group chat ID. |
| `/get_id` | `<@username>` | Search administrators in this chat by username. |
| `/get_id` | `<displayname>` | Search members in this chat by display name. |
| `/create_submit_forward` | `<dest_chat_id> <review_group_id> [forward_message]` | Create a submission forward link. Bot must be admin in both destination and review groups. |
| `/show_c_forward` | `[page]` | List active forward links for this chat with pagination. |
| `/add_blacklist` | `<user_id>` | Blacklist a user in **all active forwards** for this source chat. |
| `/rm_blacklist` | `<user_id>` | Remove a user from the blacklist in **all active forwards** for this source chat. |
| `/sban` | `@user <dur> <unit> [reason]` | Ban a user for a specified duration. |
| `/smute` | `@user <dur> <unit> [reason]` | Mute a user for a specified duration. |
| `/mute` | `@user [reason]` | Mute a user indefinitely. |
| `/pban` | `@user [reason]` | Permanently ban a user. |
| `/kick` | `@user [reason]` | Kick a user from the group. |
| `/rmute` | `@user` | Revoke an active mute and restore the user's chat permissions. |
| `/rban` | `@user` | Revoke an active ban and unban the user. |
---
## User Commands (DM)
| Command | Args | Description |
|---------|------|-------------|
| `/start` | none | Start the bot. Displays terms if not accepted, otherwise shows the main menu. |
| `/start` | `submitfwdid<code>` | Deep-link entry into **Submission Mode** for a forward. |
| `/cancel` | none | Cancel the current operation and return to the main menu. |
---
## Callback Actions
Callbacks use the format `v1:<namespace>:<action>[:<id>]`.
### Terms
| Callback | Description |
|----------|-------------|
| `v1:terms:accept` | Accept the terms of service. |
| `v1:terms:reject` | Reject the terms of service. |
### Main Menu
| Callback | Description |
|----------|-------------|
| `v1:menu:upload_media` | Enter media upload staging. |
| `v1:menu:upload_doc` | Enter document upload staging. |
| `v1:menu:upload_text` | Enter text upload staging. |
| `v1:menu:prev_uploads` | View previous uploads. |
| `v1:menu:report` | Enter content reporting flow. |
| `v1:menu:main` | Return to main menu. |
### Staging
| Callback | Description |
|----------|-------------|
| `v1:stage:confirm` | Confirm staged items and proceed to upload options. |
| `v1:stage:cancel` | Cancel the upload and return to main menu. |
### Upload Options
| Callback | Description |
|----------|-------------|
| `v1:opt:toggle_destroy` | Cycle auto-destroy max views (Off → 1 → 3 → 5 → 10 → 50 → Off). |
| `v1:opt:toggle_download` | Toggle the "allow download" flag. |
| `v1:opt:set_password` | Prompt user to send a password (or `/skip`). |
| `v1:opt:confirm_final` | Confirm options and finalize the upload. |
| `v1:opt:back` | Go back to upload staging. |
### Previous Uploads
| Callback | Description |
|----------|-------------|
| `v1:prev:page:{page}` | Navigate to a specific page of previous uploads. |
### Submission Mode
| Callback | Description |
|----------|-------------|
| `v1:submit:continue` | Continue into submission upload flow. |
| `v1:submit:exit` | Exit submission mode and return to main menu. |
### Admin / Moderation
| Callback | Description |
|----------|-------------|
| `v1:admin:delcontent:{cxid}` | Delete a content item by its CXID. |
| `v1:admin:delblk:{report_id}` | Delete reported content and blacklist the uploader. |
| `v1:admin:del:{report_id}` | Delete reported content only. |
| `v1:admin:blk:{report_id}` | Blacklist the uploader of reported content only. |
| `v1:admin:ign:{report_id}` | Ignore/dismiss the report. |
### Forward Submissions
| Callback | Description |
|----------|-------------|
| `v1:fwd:approve:{submission_id}` | Approve a forward submission and post it to the destination chat. |
| `v1:fwd:ignore:{submission_id}` | Reject a forward submission. |
| `v1:fwd:blk:{submission_id}` | Blacklist the submitting user from the forward. |
| `v1:fwd:revoke:{forward_id}` | Revoke a forward link. |
| `v1:fwd:page:{page}` | Navigate forward link list pages. |

223
docs/FORWARD_SYSTEM.md Normal file
View File

@@ -0,0 +1,223 @@
# Forward Submission System
This document describes the submission-forward flow that allows users to upload content through the bot for moderator review before it is posted to a destination channel or group.
---
## Database Schema
Defined in `migrations/003_forward_system.sql`.
### `forward_definitions`
```sql
CREATE TABLE forward_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_user_id INTEGER NOT NULL,
source_chat_id INTEGER NOT NULL,
destination_chat_id INTEGER NOT NULL,
review_group_id INTEGER NOT NULL,
forward_message TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL UNIQUE,
share_mode TEXT NOT NULL DEFAULT 'b',
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT datetime('now')
);
```
| Field | Description |
|-------|-------------|
| `id` | Primary key. |
| `creator_user_id` | Telegram ID of the admin who created the forward. |
| `source_chat_id` | The group/chat where `/create_submit_forward` was invoked. |
| `destination_chat_id` | The target channel/group where approved content is posted. |
| `review_group_id` | The moderator group where submissions are sent for review. |
| `forward_message` | Optional template text prepended to approved posts. |
| `code` | Unique 16-character alphanumeric access code. |
| `share_mode` | `'b'` = blacklist mode (default), `'w'` = whitelist mode. |
| `revoked_at` | Timestamp if the forward link was revoked; `NULL` while active. |
**Indexes:** `idx_forward_code`, `idx_forward_source`.
### `forward_submissions`
```sql
CREATE TABLE forward_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
content_id TEXT NOT NULL REFERENCES contents(id),
status TEXT NOT NULL DEFAULT 'pending',
review_message_id INTEGER,
created_at TEXT NOT NULL DEFAULT datetime('now'),
resolved_at TEXT,
resolver_id INTEGER
);
```
| Field | Description |
|-------|-------------|
| `id` | Primary key (submission number). |
| `forward_id` | The forward this submission belongs to. |
| `user_id` | Telegram ID of the submitting user. |
| `content_id` | The uploaded content entry (`contents.id`). |
| `status` | `pending`, `approved`, `ignored`, or `blacklisted`. |
| `review_message_id` | Telegram message ID of the review post in the review group. |
| `resolved_at` | Timestamp when a moderator acted on the submission. |
| `resolver_id` | Telegram ID of the moderator who resolved it. |
**Indexes:** `idx_fwd_sub_forward`, `idx_fwd_sub_user`, `idx_fwd_sub_status`.
### `forward_lists`
```sql
CREATE TABLE forward_lists (
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
list_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT datetime('now'),
PRIMARY KEY (forward_id, user_id, list_type)
);
```
| Field | Description |
|-------|-------------|
| `forward_id` | The forward definition. |
| `user_id` | The affected user. |
| `list_type` | `blacklist` or `allow`. |
This table implements **per-forward** scoped access control.
---
## Creating a Forward (`/create_submit_forward`)
**Usage (group-only, admin-gated):**
```
/create_submit_forward <destination_chat_id> <review_group_id> [forward_message]
```
**Requirements:**
1. Caller must be an **administrator or owner** of the source group.
2. The bot must be an **administrator** in both the `destination_chat_id` and `review_group_id`.
**What happens:**
1. A 16-character alphanumeric `code` is generated (`generate_forward_code`).
2. A row is inserted into `forward_definitions` with:
- `source_chat_id` = current chat
- `share_mode` = `'b'` (blacklist mode)
3. The bot replies with a deep-link URL:
```
https://t.me/<bot_username>?start=submitfwdid<code>
```
---
## Entering Submission Mode
Users click the deep link or send:
```
/start submitfwdid<CODE>
```
**Validation:**
1. The code is looked up in `forward_definitions`.
2. If the forward has been revoked (`revoked_at IS NOT NULL`), the user is told the link is revoked.
3. The scoped access check `ForwardRepo::is_allowed(forward_id, user_id)` is performed:
- The creator is always allowed.
- In **blacklist mode** (`'b'`): allowed unless the user has a `blacklist` entry.
- In **whitelist mode** (`'w'`): allowed only if the user has an `allow` entry.
4. If allowed, the bot enters `BotState::SubmitMode { forward_id, code }` and presents **Continue / Exit** buttons.
---
## Submission Flow
1. **Continue** — The user is transitioned to `BotState::MainMenu { pending_forward_id: Some(forward_id) }`. All uploads staged from this point are tagged with the pending forward ID.
2. **Upload** — The user stages files (media, documents, or text) and confirms options just like a normal upload.
3. **Finalize** — When the user confirms, `finalize_upload`:
- Creates and encrypts the content entry.
- Inserts a row into `forward_submissions` with `status = 'pending'`.
- Posts a review message to the `review_group_id` with inline buttons:
- `[ Approve ]` → callback `v1:fwd:approve:{submission_id}`
- `[ Ignore ]` → callback `v1:fwd:ignore:{submission_id}`
- `[ Blacklist User ]` → callback `v1:fwd:blk:{submission_id}`
- Stores the sent message ID back into `forward_submissions.review_message_id`.
4. **Review** — Moderators in the review group click the buttons to act.
---
## Review Actions
All review callbacks require the clicking user to be an **administrator in the review group** (`is_admin_in_chat`).
### Approve (`v1:fwd:approve`)
1. Generates a random 12-character direct-access password (`generate_direct_password`).
2. Hashes the password with Argon2 and stores it in `contents.password_hash`.
3. Builds the direct link: `{base_url}/?cxid={content_id}&sc={password}`.
4. Posts the link to the destination chat, prefixed with `forward_message` (if set).
5. DM the submitter:
- "Your submission was approved."
- Includes the posted message URL and the direct access link.
6. Edits the review message to show `[ APPROVED ]` and the moderator ID.
7. Sets `forward_submissions.status = 'approved'`.
### Ignore (`v1:fwd:ignore`)
1. DM the submitter: "Your submission was rejected."
2. Edits the review message to show `[ IGNORED ]` and the moderator ID.
3. Sets `forward_submissions.status = 'ignored'`.
### Blacklist User (`v1:fwd:blk`)
1. Adds the submitter to `forward_lists` with `list_type = 'blacklist'` for this forward.
2. Edits the review message to show `[ BLACKLISTED ]` and the moderator ID.
3. Sets `forward_submissions.status = 'blacklisted'`.
4. The user is now blocked from using this forward link again (until removed).
---
## Management Commands
All group-only, admin-gated.
### `/show_c_forward [page]`
Lists forward links created in the current source chat (5 per page).
- Shows code, destination chat ID, review group ID, and status (`Active` or `Revoked`).
- Active forwards include an inline `[ Revoke ]` button.
- Pagination via `<<` / `>>` buttons.
### `/add_blacklist <user_id>`
Iterates all **active** forwards for the current source chat and inserts the user into each forward's `forward_lists` as `blacklist`.
Replies with the count of forwards affected.
### `/rm_blacklist <user_id>`
Iterates all **active** forwards for the current source chat and removes the user from each forward's `forward_lists` where `list_type = 'blacklist'`.
Replies with the count of forwards affected.
---
## Scoped Access Model
Each forward has its own independent access list stored in `forward_lists`.
| `share_mode` | Behavior |
|--------------|----------|
| `'b'` (blacklist) | **Default.** Everyone is allowed unless explicitly blacklisted. |
| `'w'` (whitelist) | Only explicitly allowed users (and the creator) may submit. |
**Note:** The `share_mode` is stored per forward but there is currently no admin command to change it after creation; it defaults to `'b'` at creation time.
---
## Revoking a Forward
Admins or the creator can revoke a forward via the `[ Revoke ]` button on `/show_c_forward`.
- Validates the forward belongs to the current chat.
- Requires creator status **or** admin status.
- Sets `revoked_at = datetime('now')`.
- Revoked forwards reject new submissions immediately.

149
docs/MODERATION.md Normal file
View File

@@ -0,0 +1,149 @@
# Moderation & Punishment System
This document describes the punishment system implemented in the bot.
---
## Database Schema
Defined in `migrations/004_punishments.sql`.
```sql
CREATE TABLE punishments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick'
duration_seconds INTEGER, -- NULL = permanent / indefinite
reason TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT datetime('now'),
revoked_at TEXT,
revoked_by INTEGER,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id);
CREATE INDEX idx_punishments_active ON punishments(active);
```
### Field Reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `INTEGER` | Primary key. |
| `chat_id` | `INTEGER` | The Telegram group/chat where the punishment was applied. |
| `target_user_id` | `INTEGER` | The punished user's Telegram ID. |
| `action_type` | `TEXT` | One of `ban`, `mute`, or `kick`. |
| `duration_seconds` | `INTEGER` | Duration before auto-expiration. `NULL` means **permanent / indefinite**. |
| `reason` | `TEXT` | Optional moderator-provided reason. |
| `created_by` | `INTEGER` | Telegram ID of the moderator who issued the punishment. |
| `created_at` | `TEXT` | ISO timestamp when the punishment was created. |
| `revoked_at` | `TEXT` | ISO timestamp when the punishment was manually or automatically revoked. |
| `revoked_by` | `INTEGER` | Telegram ID of the revoker, or `0` for the system (auto-expiry). |
| `active` | `INTEGER` | `1` if the punishment is still in effect, `0` if revoked. |
---
## Command Reference
All punishment commands require the caller to be an **administrator or owner** of the group.
### Duration-based Punishments
| Command | Syntax | Description |
|---------|--------|-------------|
| `/sban` | `/sban @user <dur> <unit> [reason]` | Ban the user for a specific duration. |
| `/smute` | `/smute @user <dur> <unit> [reason]` | Mute the user for a specific duration. |
### Permanent / Indefinite Punishments
| Command | Syntax | Description |
|---------|--------|-------------|
| `/mute` | `/mute @user [reason]` | Mute the user **indefinitely** (`duration_seconds = NULL`). |
| `/pban` | `/pban @user [reason]` | Permanently ban the user (`duration_seconds = NULL`). |
| `/kick` | `/kick @user [reason]` | Kick the user from the group. Always recorded with `NULL` duration. |
### Revoke Commands
| Command | Syntax | Description |
|---------|--------|-------------|
| `/rmute` | `/rmute @user` | Revoke the active mute for this user and restore chat permissions. |
| `/rban` | `/rban @user` | Revoke the active ban for this user and unban them. |
### Target Resolution
The `@user` argument is resolved in the following order:
1. Numeric user ID.
2. `@username` — matched against chat administrators.
If the target cannot be resolved, the bot replies with *"Could not resolve target user."*
---
## Duration Units Reference
The `parse_duration` function in `crates/cgcx-bot/src/main.rs` accepts the following units (case-insensitive):
| Unit(s) | Seconds | Example |
|---------|---------|---------|
| `s`, `sec`, `secs`, `second`, `seconds` | 1 | `/sban @user 30 s spam` |
| `m`, `min`, `mins`, `minute`, `minutes` | 60 | `/smute @user 10 m offtopic` |
| `h`, `hr`, `hrs`, `hour`, `hours` | 3,600 | `/sban @user 24 h raid` |
| `d`, `day`, `days` | 86,400 | `/sban @user 7 d trolling` |
| `w`, `week`, `weeks` | 604,800 | `/smute @user 2 w` |
| `mo`, `month`, `months` | 2,592,000 (30 days) | `/sban @user 1 mo` |
| `y`, `year`, `years` | 31,536,000 (365 days) | `/pban @user 1 y` |
---
## How Expiration Works (Background Task)
When the bot starts, a background Tokio task is spawned that runs every **60 seconds**:
1. **Query** — Calls `PunishmentRepo::list_expired()`, which selects rows where:
- `active = 1`
- `duration_seconds IS NOT NULL`
- `datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')`
2. **Action per expired punishment**:
- **`ban`** — Calls `unban_chat_member(chat_id, target_user_id)` to lift the ban.
- **`mute`** — Calls `restrict_chat_member` with restored permissions:
- `SEND_MESSAGES`
- `SEND_MEDIA_MESSAGES`
- `SEND_OTHER_MESSAGES`
- `ADD_WEB_PAGE_PREVIEWS`
- **Other types** — No automatic Telegram action is taken.
3. **Record update** — Calls `repo.revoke(p.id, 0)`:
- Sets `active = 0`
- Sets `revoked_at = datetime('now')`
- Sets `revoked_by = 0` (system)
---
## How Revoke Works
### Manual Revoke (`/rmute`, `/rban`)
1. The bot resolves the target user.
2. Queries `get_active_for_chat_target(chat_id, target_user_id, action_type)` to find the active punishment.
3. If found:
- **`/rmute`** — Restores the user's chat permissions via `restrict_chat_member(...)`.
- **`/rban`** — Unbans the user via `unban_chat_member(...)`.
- Calls `repo.revoke(p.id, admin_user_id)` to mark the punishment inactive.
4. If no active punishment is found, the bot replies with *"No active mute/ban found for this user."*
### Revoke via `PunishmentRepo::revoke(id, revoked_by)`
```sql
UPDATE punishments
SET active = 0,
revoked_at = datetime('now'),
revoked_by = ?1
WHERE id = ?2;
```
This is used both for:
- **Automatic expiration** (`revoked_by = 0`)
- **Manual moderator revocation** (`revoked_by = moderator_user_id`)

137
docs/OPERATIONAL_NOTES.md Normal file
View File

@@ -0,0 +1,137 @@
# Operational Notes
This document covers runtime behaviors, limits, and maintenance considerations for operating a cg.cx instance.
---
## Telegram API Rate Limits
The bot does **not** implement explicit request throttling for Telegram API calls. It relies on Teloxide's default behavior and the Telegram Bot API flood-control semantics.
- **Forwarding / posting messages** — Subject to standard Bot API rate limits (roughly ~30 messages/second in groups, lower in smaller chats). Rapid approval of many submissions may trigger `RetryAfter` errors; the bot currently does not back off explicitly.
- **Banning / restricting members** — `banChatMember` and `restrictChatMember` have aggressive per-chat limits. Issuing many punishment commands in quick succession may result in temporary API rejections.
- **Message deletion** — `deleteMessage` is limited to ~300 deletions per chat per 24 hours for bots. The automatic service-message cleanup (see below) contributes to this budget.
**Operational recommendation:** If running in high-traffic groups, monitor bot logs for `RetryAfter` or `429` errors and consider spacing out bulk operations.
---
## System Message Deletion Limits
The bot automatically deletes service messages in groups and channels to reduce noise. In `handle_message_inner`, the following 17 message types are detected and deleted in non-private chats:
- `new_chat_members`
- `left_chat_member`
- `new_chat_title`
- `new_chat_photo`
- `delete_chat_photo`
- `group_chat_created`
- `supergroup_chat_created`
- `channel_chat_created`
- `migrate_to_chat_id`
- `migrate_from_chat_id`
- `pinned_message`
- `video_chat_scheduled`
- `video_chat_started`
- `video_chat_ended`
- `video_chat_participants_invited`
- `message_auto_delete_timer_changed`
- `proximity_alert_triggered`
**Limitations:**
- Some service messages (e.g., `channel_chat_created`) **cannot be deleted by bots** and will silently fail. The code handles this with `let _ = bot.delete_message(...).await;`.
- Deletion failures do not crash the bot or block subsequent message processing.
---
## Storage & Directories
Encrypted content is organized into the following directories (configured in `config/default.toml` under `[storage.paths]`):
| Directory | Purpose |
|-----------|---------|
| `data/media` | Image, video, and audio files (`image/*`, `video/*`, `audio/*`). |
| `data/documents` | All other file types (archives, binaries, etc.). |
| `data/text` | Plain text uploads (`text/*` MIME types). |
| `data/temp` | Temporary files during encryption and upload processing. |
| `data/logs` | Rolling log output from the bot and server. |
**Directory creation:** Both the bot and server call `storage.ensure_dirs().await` at startup, creating missing directories automatically.
---
## Rolling Log Files
Both the bot (`crates/cgcx-bot/src/main.rs`) and the server (`crates/cgcx-server/src/main.rs`) use `tracing-appender` for daily log rotation:
```rust
tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(log_prefix)
.max_log_files(config.logging.max_files)
.build(log_dir)
```
- **Rotation:** Daily.
- **Retention:** `max_files` (default: `7`).
- **Paths:**
- Bot: `data/logs/cgcx-bot.log` (or configured `logging.file_path`)
- Server: `data/logs/cgcx-server.log`
- **Format:** Plain text, ANSI colors disabled for file output.
- **Fallback:** If the rolling appender fails to initialize, the process falls back to console-only logging.
---
## SQLite WAL Mode
Every database connection is opened with:
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
```
**Implications:**
- **WAL (Write-Ahead Logging)** allows readers to proceed without blocking on writers, which is important because the bot and server may share the same SQLite file.
- A `busy_timeout` of 5000 ms reduces "database is locked" errors under concurrent load.
- WAL produces companion files (`db.sqlite-wal`, `db.sqlite-shm`) in the same directory as the database. These are safe to leave in place during normal operation and are automatically checkpointed by SQLite.
---
## Background Task Intervals
| Task | Interval | Description |
|------|----------|-------------|
| **Punishment expiration** | 60 seconds | Bot task that queries `punishments` for expired timed bans/mutes and lifts them. |
| **Orphan cleanup** | 24 hours | Server task that runs `FilePipeline::cleanup_orphans()` to remove files belonging to deleted/blacklisted content (only if `keep_content = false`). |
**Note:** The orphan sweeper skips its first tick on startup to avoid immediate load spikes.
---
## Frontend Chunk Size Warning
The frontend build uses Vite with its default configuration. During `npm run build`, Vite may emit warnings such as:
```
(!) Some chunks are larger than 500 kBs after minification.
```
- This is a **non-blocking** warning; the build completes successfully.
- The warning typically comes from large vendor dependencies (e.g., PDF.js, syntax highlighters).
- No custom `chunkSizeWarningLimit` is configured; the default Vite behavior is accepted.
---
## HTTP Rate Limiting (Server)
The Axum server uses `tower-governor` for per-IP rate limiting:
| Route Group | Config Key | Default | Burst |
|-------------|-----------|---------|-------|
| General API (`/api/health`, `/api/content/...`) | `rate_limiting.requests_per_minute` | 60 | 10 |
| Password verification (`POST /api/content/:cxid/verify-password`) | `rate_limiting.password_attempts_per_minute` | 4 | 3 |
- Exceeding the general limit returns `429 Too Many Requests`.
- The password endpoint has a separate, stricter limit to mitigate brute-force attacks.

View File

@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"mammoth": "^1.12.0",
"marked": "^12.0.0"
},
"devDependencies": {
@@ -467,6 +469,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -480,6 +491,15 @@
"node": ">=0.4.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -500,6 +520,32 @@
"node": ">= 0.4"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -510,6 +556,12 @@
"node": ">=6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -537,6 +589,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
@@ -546,6 +604,15 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -604,6 +671,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -614,6 +702,33 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -894,6 +1009,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -904,6 +1030,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
@@ -946,6 +1096,27 @@
],
"license": "MIT"
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -995,6 +1166,27 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
@@ -1029,6 +1221,18 @@
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1039,6 +1243,21 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/svelte": {
"version": "5.55.9",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
@@ -1092,6 +1311,18 @@
"license": "0BSD",
"optional": true
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
@@ -1190,6 +1421,15 @@
}
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View File

@@ -14,6 +14,8 @@
},
"dependencies": {
"dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"mammoth": "^1.12.0",
"marked": "^12.0.0"
}
}

View File

@@ -0,0 +1,92 @@
<script>
import { detectLanguage } from '../lib/lang.js'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark.css'
let { src, rawUrl = '', fileName = '' } = $props()
let text = $state('')
let loading = $state(true)
let lang = $derived(detectLanguage(fileName))
$effect(() => {
fetch(src)
.then(r => r.text())
.then(t => {
text = t
loading = false
if (lang) {
requestAnimationFrame(() => {
const block = document.querySelector('.code-viewer code')
if (block) hljs.highlightElement(block)
})
}
})
.catch(() => {
text = 'Failed to load code content.'
loading = false
})
})
</script>
<div class="code-viewer">
{#if fileName || rawUrl}
<div class="header">
{#if fileName}<span class="label">{fileName}</span>{/if}
{#if rawUrl}<a class="raw-btn" href={rawUrl} target="_blank">[ Raw ]</a>{/if}
</div>
{/if}
{#if loading}
<p>Loading code...</p>
{:else}
<pre><code class={lang ? `language-${lang}` : ''}>{text}</code></pre>
{/if}
</div>
<style>
.code-viewer {
max-width: 1000px;
margin: 24px auto;
padding: 16px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--retro-border);
}
.label {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-green);
}
.raw-btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.5rem;
padding: 6px 10px;
border: 2px solid var(--retro-border);
background: var(--retro-panel);
color: var(--retro-fg);
text-decoration: none;
box-shadow: 2px 2px 0px rgba(0,0,0,0.15);
}
.raw-btn:hover {
background: var(--retro-green);
color: #fff;
}
pre {
margin: 0;
overflow-x: auto;
background: #1e1e1e;
padding: 12px;
border: 2px solid var(--retro-border);
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,98 @@
<script>
let { src, downloadUrl, file } = $props()
let html = $state('')
let loading = $state(true)
let error = $state('')
$effect(() => {
fetch(src)
.then(r => r.arrayBuffer())
.then(buf => import('mammoth').then(m => m.default.convertToHtml({ arrayBuffer: buf })))
.then(result => {
html = result.value
loading = false
})
.catch(() => {
error = 'Failed to render DOCX.'
loading = false
})
})
</script>
<div class="docx-viewer">
<div class="header">
<span class="label">[ DOCX ]</span>
<a class="raw-btn" href={downloadUrl} download={file.name}>Download</a>
</div>
{#if loading}
<p>Loading DOCX...</p>
{:else if error}
<p class="error">{error}</p>
<a class="btn" href={downloadUrl} download={file.name}>Download</a>
{:else}
<div class="docx-content">{@html html}</div>
{/if}
</div>
<style>
.docx-viewer {
max-width: 900px;
margin: 24px auto;
padding: 24px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--retro-border);
}
.label {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-green);
}
.raw-btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.5rem;
padding: 6px 10px;
border: 2px solid var(--retro-border);
background: var(--retro-panel);
color: var(--retro-fg);
text-decoration: none;
box-shadow: 2px 2px 0px rgba(0,0,0,0.15);
}
.raw-btn:hover {
background: var(--retro-green);
color: #fff;
}
.docx-content {
font-size: 1rem;
line-height: 1.6;
}
.docx-content :global(p) { margin: 0.8em 0; }
.docx-content :global(table) { border-collapse: collapse; width: 100%; }
.docx-content :global(td), .docx-content :global(th) { border: 1px solid #ccc; padding: 6px; }
.error { color: var(--retro-danger); }
.btn {
display: inline-block;
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
padding: 10px 14px;
border: 3px solid var(--retro-border);
background: var(--retro-panel);
color: var(--retro-fg);
text-decoration: none;
text-align: center;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
margin-top: 8px;
}
.btn:hover {
background: var(--retro-green);
color: #fff;
}
</style>

View File

@@ -1,9 +1,14 @@
<script>
let { src, name } = $props()
let failed = $state(false)
</script>
<div class="image-viewer">
<img {src} alt={name} decoding="async" loading="eager" />
{#if failed}
<div class="image-error">[ Failed to load image ]</div>
{:else}
<img {src} alt={name} decoding="async" loading="eager" onerror={() => failed = true} />
{/if}
</div>
<style>
@@ -19,4 +24,10 @@
box-shadow: 6px 6px 0px var(--retro-shadow);
image-rendering: auto;
}
.image-error {
color: var(--retro-danger);
font-family: var(--retro-font, monospace);
padding: 24px;
text-align: center;
}
</style>

View File

@@ -1,12 +1,17 @@
<script>
import { fileUrl } from '../lib/api.js'
import { fileUrl, rawUrl } from '../lib/api.js'
import { detectLanguage } from '../lib/lang.js'
import ImageViewer from './ImageViewer.svelte'
import VideoPlayer from './VideoPlayer.svelte'
import AudioPlayer from './AudioPlayer.svelte'
import MarkdownRenderer from './MarkdownRenderer.svelte'
import TextViewer from './TextViewer.svelte'
import CodeViewer from './CodeViewer.svelte'
import DocumentCard from './DocumentCard.svelte'
import ExecutableWarning from './ExecutableWarning.svelte'
import SensitiveWarning from './SensitiveWarning.svelte'
import PdfViewer from './PdfViewer.svelte'
import DocxViewer from './DocxViewer.svelte'
let { files, cxid, password = '' } = $props()
@@ -17,7 +22,11 @@
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 32) {
return file.mime === 'application/pdf' ? 'pdf' : 'docx'
}
if (flags & 64 || flags & 128) return 'dangerous'
if (flags & 512) return 'sensitive'
return 'document'
}
</script>
@@ -39,9 +48,19 @@
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{#if detectLanguage(file.name)}
<CodeViewer src={fileUrl(cxid, file.idx, false, password)} rawUrl={rawUrl(cxid, file.idx, password)} fileName={file.name} />
{:else}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} rawUrl={rawUrl(cxid, file.idx, password)} fileName={file.name} />
{/if}
{:else if viewer === 'pdf'}
<PdfViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'docx'}
<DocxViewer src={fileUrl(cxid, file.idx, false, password)} downloadUrl={fileUrl(cxid, file.idx, true, password)} {file} />
{:else if viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else if viewer === 'sensitive'}
<SensitiveWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}

View File

@@ -0,0 +1,23 @@
<script>
let { src } = $props()
</script>
<div class="pdf-viewer">
<embed src={src} type="application/pdf" />
</div>
<style>
.pdf-viewer {
max-width: 1000px;
margin: 24px auto;
padding: 16px;
background: var(--retro-panel);
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
embed {
width: 100%;
height: 80vh;
border: 2px solid var(--retro-border);
}
</style>

View File

@@ -0,0 +1,56 @@
<script>
import { formatSize } from '../lib/api.js'
let { file, downloadUrl } = $props()
</script>
<div class="warning-card">
<div class="badge">[ Sensitive Data ]</div>
<p class="name">{file.name}</p>
<p class="meta">{file.mime}{formatSize(file.size)}</p>
<p class="notice">
This file may contain sensitive data. Be careful when handling it.
</p>
<a class="btn" href={downloadUrl} download={file.name}>Download</a>
</div>
<style>
.warning-card {
max-width: 600px;
margin: 24px auto;
padding: 24px;
background: #fffdf5;
border: 3px solid #c78000;
box-shadow: 6px 6px 0px rgba(199,128,0,0.15);
text-align: center;
}
.badge {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: #c78000;
margin-bottom: 12px;
}
.name {
font-family: 'Press Start 2P', cursive;
font-size: 0.7rem;
word-break: break-all;
}
.meta {
font-size: 0.9rem;
color: #666;
margin: 8px 0;
}
.notice {
font-size: 1rem;
color: #555;
margin: 16px 0;
}
.btn {
border-color: #c78000;
color: #c78000;
}
.btn:hover {
background: #c78000;
color: #fff;
}
</style>

View File

@@ -1,5 +1,5 @@
<script>
let { src } = $props()
let { src, rawUrl = '', fileName = '' } = $props()
let text = $state('')
let loading = $state(true)
@@ -18,6 +18,12 @@
</script>
<div class="text-viewer">
{#if fileName || rawUrl}
<div class="header">
{#if fileName}<span class="label">{fileName}</span>{/if}
{#if rawUrl}<a class="raw-btn" href={rawUrl} target="_blank">[ Raw ]</a>{/if}
</div>
{/if}
{#if loading}
<p>Loading text...</p>
{:else}
@@ -34,6 +40,33 @@
border: 3px solid var(--retro-border);
box-shadow: 6px 6px 0px var(--retro-shadow);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--retro-border);
}
.label {
font-family: 'Press Start 2P', cursive;
font-size: 0.6rem;
color: var(--retro-green);
}
.raw-btn {
font-family: 'Press Start 2P', cursive;
font-size: 0.5rem;
padding: 6px 10px;
border: 2px solid var(--retro-border);
background: var(--retro-panel);
color: var(--retro-fg);
text-decoration: none;
box-shadow: 2px 2px 0px rgba(0,0,0,0.15);
}
.raw-btn:hover {
background: var(--retro-green);
color: #fff;
}
pre {
white-space: pre-wrap;
word-break: break-word;

View File

@@ -4,7 +4,9 @@
<div class="video-player">
<!-- svelte-ignore a11y_media_has_caption -->
<video controls preload="metadata" {src}></video>
<video controls preload="metadata" {src}>
<source src={src} type={mime} />
</video>
</div>
<style>

View File

@@ -1,10 +1,10 @@
// "window.location.origin"
const API_BASE = "http://127.0.0.1:8090";
export async function fetchMetadata(cxid) {
const res = await fetch(
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
);
export async function fetchMetadata(cxid, password = "") {
let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}`;
if (password) url += `?sc=${encodeURIComponent(password)}`;
const res = await fetch(url);
if (!res.ok) {
const err = new Error(await res.text());
err.status = res.status;
@@ -34,6 +34,12 @@ export function fileUrl(cxid, fileIdx, download = false, password = "") {
return url;
}
export function rawUrl(cxid, fileIdx, password = "") {
let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}/file/${fileIdx}/raw`;
if (password) url += `?sc=${encodeURIComponent(password)}`;
return url;
}
export function formatSize(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];

16
frontend/src/lib/lang.js Normal file
View File

@@ -0,0 +1,16 @@
const EXT_TO_LANG = {
py: 'python', rs: 'rust', js: 'javascript', ts: 'typescript',
jsx: 'javascript', tsx: 'typescript', c: 'c', cpp: 'cpp', cc: 'cpp',
h: 'c', hpp: 'cpp', go: 'go', java: 'java', kt: 'kotlin',
swift: 'swift', rb: 'ruby', php: 'php', cs: 'csharp', scala: 'scala',
r: 'r', m: 'objectivec', mm: 'objectivec', pl: 'perl', lua: 'lua',
json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml', toml: 'toml',
ini: 'ini', cfg: 'ini', sh: 'bash', bash: 'bash', ps1: 'powershell',
bat: 'batch', cmd: 'batch', sql: 'sql', dockerfile: 'dockerfile',
makefile: 'makefile', cmake: 'cmake',
};
export function detectLanguage(fileName) {
const ext = fileName.split('.').pop()?.toLowerCase();
return ext ? (EXT_TO_LANG[ext] || null) : null;
}

View File

@@ -64,6 +64,13 @@
<button onclick={submit} disabled={loading}>
{loading ? 'Loading...' : '[ Unlock ]'}
</button>
<details class="misc-section">
<summary>[ Misc ]</summary>
<div class="misc-content">
<a href="https://t.me/harmfulmeowbot?start=report" target="_blank" rel="noopener">Report Content</a>
</div>
</details>
</div>
<footer>
@@ -135,4 +142,31 @@
margin-top: 12px;
color: #777;
}
.misc-section {
margin-top: 8px;
border: 2px solid var(--retro-border);
padding: 8px 12px;
background: #f5f5f5;
}
.misc-section summary {
font-family: 'Press Start 2P', cursive;
font-size: 0.55rem;
color: var(--retro-green);
cursor: pointer;
user-select: none;
}
.misc-content {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.misc-content a {
font-size: 0.9rem;
color: var(--retro-green);
text-decoration: none;
}
.misc-content a:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,12 +1,17 @@
<script>
import { fetchMetadata, verifyPassword, fileUrl } from '../lib/api.js'
import { fetchMetadata, verifyPassword, fileUrl, rawUrl } from '../lib/api.js'
import { detectLanguage } from '../lib/lang.js'
import ImageViewer from '../components/ImageViewer.svelte'
import VideoPlayer from '../components/VideoPlayer.svelte'
import AudioPlayer from '../components/AudioPlayer.svelte'
import MarkdownRenderer from '../components/MarkdownRenderer.svelte'
import TextViewer from '../components/TextViewer.svelte'
import CodeViewer from '../components/CodeViewer.svelte'
import DocumentCard from '../components/DocumentCard.svelte'
import ExecutableWarning from '../components/ExecutableWarning.svelte'
import SensitiveWarning from '../components/SensitiveWarning.svelte'
import PdfViewer from '../components/PdfViewer.svelte'
import DocxViewer from '../components/DocxViewer.svelte'
import MixedGallery from '../components/MixedGallery.svelte'
let { cxid, sc } = $props()
@@ -28,28 +33,18 @@
phase = 'loading_meta'
error = ''
try {
const meta = await fetchMetadata(cxid)
const meta = await fetchMetadata(cxid, password)
metadata = meta
if (meta.has_password && !password) {
phase = 'password_required'
return
}
if (meta.has_password) {
const ok = await verifyPassword(cxid, password)
if (!ok) {
phase = 'password_required'
error = 'Incorrect password.'
return
}
}
phase = 'rendering'
} catch (e) {
phase = 'error'
const status = e.status || 0
if (status === 401) {
phase = 'password_required'
return
}
phase = 'error'
if (status === 404) {
error = '[ Not Found ] This content does not exist or has been removed.'
} else if (status === 401) {
error = '[ Unauthorized ] This content requires a password.'
} else if (status === 429) {
error = '[ Rate Limited ] Too many requests. Please wait.'
} else if (status >= 500) {
@@ -65,6 +60,7 @@
if (!password) return
const ok = await verifyPassword(cxid, password)
if (ok) {
metadata = await fetchMetadata(cxid, password)
phase = 'rendering'
} else {
error = 'Incorrect password.'
@@ -83,8 +79,12 @@
if (flags & 4) return 'audio'
if (flags & 8) return 'markdown'
if (flags & 16) return 'text'
if (flags & 32) {
return file.mime === 'application/pdf' ? 'pdf' : 'docx'
}
if (flags & 64) return 'executable'
if (flags & 128) return 'dangerous'
if (flags & 512) return 'sensitive'
return 'document'
}
</script>
@@ -133,9 +133,19 @@
{:else if viewer === 'markdown'}
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'text'}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} />
{#if detectLanguage(file.name)}
<CodeViewer src={fileUrl(cxid, file.idx, false, password)} rawUrl={rawUrl(cxid, file.idx, password)} fileName={file.name} />
{:else}
<TextViewer src={fileUrl(cxid, file.idx, false, password)} rawUrl={rawUrl(cxid, file.idx, password)} fileName={file.name} />
{/if}
{:else if viewer === 'pdf'}
<PdfViewer src={fileUrl(cxid, file.idx, false, password)} />
{:else if viewer === 'docx'}
<DocxViewer src={fileUrl(cxid, file.idx, false, password)} downloadUrl={fileUrl(cxid, file.idx, true, password)} {file} />
{:else if viewer === 'executable' || viewer === 'dangerous'}
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else if viewer === 'sensitive'}
<SensitiveWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{:else}
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
{/if}

View File

@@ -0,0 +1,37 @@
CREATE TABLE forward_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_user_id INTEGER NOT NULL,
source_chat_id INTEGER NOT NULL,
destination_chat_id INTEGER NOT NULL,
review_group_id INTEGER NOT NULL,
forward_message TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL UNIQUE,
share_mode TEXT NOT NULL DEFAULT 'b',
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_forward_code ON forward_definitions(code);
CREATE INDEX idx_forward_source ON forward_definitions(source_chat_id);
CREATE TABLE forward_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
content_id TEXT NOT NULL REFERENCES contents(id),
status TEXT NOT NULL DEFAULT 'pending',
review_message_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT,
resolver_id INTEGER
);
CREATE INDEX idx_fwd_sub_forward ON forward_submissions(forward_id);
CREATE INDEX idx_fwd_sub_user ON forward_submissions(user_id);
CREATE INDEX idx_fwd_sub_status ON forward_submissions(status);
CREATE TABLE forward_lists (
forward_id INTEGER NOT NULL REFERENCES forward_definitions(id),
user_id INTEGER NOT NULL,
list_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (forward_id, user_id, list_type)
);

View File

@@ -0,0 +1,15 @@
CREATE TABLE punishments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
action_type TEXT NOT NULL, -- 'ban', 'mute', 'kick'
duration_seconds INTEGER, -- NULL = permanent
reason TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT,
revoked_by INTEGER,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_punishments_chat_target ON punishments(chat_id, target_user_id);
CREATE INDEX idx_punishments_active ON punishments(active);