Huge refactor, submission system addition & security improvements. +Implementation of moderation cmds.
This commit is contained in:
82
Cargo.lock
generated
82
Cargo.lock
generated
@@ -345,6 +345,7 @@ dependencies = [
|
|||||||
"teloxide",
|
"teloxide",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -466,6 +467,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tower_governor",
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -634,6 +636,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -705,6 +716,15 @@ dependencies = [
|
|||||||
"parking_lot_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -1715,6 +1735,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1919,6 +1945,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -2524,6 +2556,12 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symlink"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -2732,6 +2770,37 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tiny-keccak"
|
name = "tiny-keccak"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -2936,6 +3005,19 @@ dependencies = [
|
|||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ teloxide = { version = "0.13", features = ["macros"] }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,24 @@ pub struct RateLimitConfig {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct LoggingConfig {
|
pub struct LoggingConfig {
|
||||||
pub level: String,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ pub const RENDER_DOCUMENT: u32 = 1 << 5;
|
|||||||
pub const RENDER_EXECUTABLE: u32 = 1 << 6;
|
pub const RENDER_EXECUTABLE: u32 = 1 << 6;
|
||||||
pub const RENDER_DANGEROUS: u32 = 1 << 7;
|
pub const RENDER_DANGEROUS: u32 = 1 << 7;
|
||||||
pub const RENDER_NO_INLINE: u32 = 1 << 8;
|
pub const RENDER_NO_INLINE: u32 = 1 << 8;
|
||||||
|
pub const RENDER_SENSITIVE: u32 = 1 << 9;
|
||||||
|
|
||||||
const DANGEROUS_EXTENSIONS: &[&str] = &[
|
const DANGEROUS_EXTENSIONS: &[&str] = &[
|
||||||
"exe", "scr", "bat", "cmd", "sh", "dll", "so", "dylib", "jar", "msi", "com", "app", "apk",
|
"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] = &[
|
const DANGEROUS_MIME_TYPES: &[&str] = &[
|
||||||
@@ -18,6 +24,11 @@ const DANGEROUS_MIME_TYPES: &[&str] = &[
|
|||||||
"text/css",
|
"text/css",
|
||||||
"application/javascript",
|
"application/javascript",
|
||||||
"application/ecmascript",
|
"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 {
|
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;
|
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) {
|
if let Some(kind) = infer::get(data) {
|
||||||
let mime = kind.mime_type();
|
let mime = kind.mime_type();
|
||||||
if mime == "application/x-executable"
|
if mime == "application/x-executable"
|
||||||
|
|||||||
@@ -84,3 +84,45 @@ pub struct AdminAction {
|
|||||||
pub action: String,
|
pub action: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ForwardDefinition {
|
||||||
|
pub id: i64,
|
||||||
|
pub creator_user_id: i64,
|
||||||
|
pub source_chat_id: i64,
|
||||||
|
pub destination_chat_id: i64,
|
||||||
|
pub review_group_id: i64,
|
||||||
|
pub forward_message: String,
|
||||||
|
pub code: String,
|
||||||
|
pub share_mode: String,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ForwardSubmission {
|
||||||
|
pub id: i64,
|
||||||
|
pub forward_id: i64,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub content_id: ContentId,
|
||||||
|
pub status: String,
|
||||||
|
pub review_message_id: Option<i32>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub resolved_at: Option<DateTime<Utc>>,
|
||||||
|
pub resolver_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Punishment {
|
||||||
|
pub id: i64,
|
||||||
|
pub chat_id: i64,
|
||||||
|
pub target_user_id: i64,
|
||||||
|
pub action_type: String,
|
||||||
|
pub duration_seconds: Option<i64>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub created_by: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
pub revoked_by: Option<i64>,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ impl Database {
|
|||||||
let migrations = rusqlite_migration::Migrations::new(vec![
|
let migrations = rusqlite_migration::Migrations::new(vec![
|
||||||
rusqlite_migration::M::up(include_str!("../../../migrations/001_init.sql")),
|
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/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)
|
migrations.to_latest(&mut *conn)
|
||||||
.map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?;
|
.map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, Report, ReportStatus, Result, CgcxError, User};
|
use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, ForwardDefinition, ForwardSubmission, Punishment, Report, ReportStatus, Result, CgcxError, User};
|
||||||
use rusqlite::{params, OptionalExtension};
|
use rusqlite::{params, OptionalExtension, Connection};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -197,6 +197,15 @@ impl ContentRepo {
|
|||||||
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
|
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
Ok(())
|
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 {
|
pub struct ContentFileRepo {
|
||||||
@@ -387,3 +396,304 @@ impl AdminActionRepo {
|
|||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ForwardRepo {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForwardRepo {
|
||||||
|
pub fn new(conn: Arc<Mutex<rusqlite::Connection>>) -> Self {
|
||||||
|
Self { conn }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, creator_user_id: i64, source_chat_id: i64, destination_chat_id: i64, review_group_id: i64, forward_message: &str, code: &str, share_mode: &str) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO forward_definitions (creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_code(&self, code: &str) -> Result<Option<ForwardDefinition>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let row = conn.query_row(
|
||||||
|
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
|
||||||
|
FROM forward_definitions WHERE code = ?1",
|
||||||
|
params![code],
|
||||||
|
|row| {
|
||||||
|
Ok(ForwardDefinition {
|
||||||
|
id: row.get(0)?,
|
||||||
|
creator_user_id: row.get(1)?,
|
||||||
|
source_chat_id: row.get(2)?,
|
||||||
|
destination_chat_id: row.get(3)?,
|
||||||
|
review_group_id: row.get(4)?,
|
||||||
|
forward_message: row.get(5)?,
|
||||||
|
code: row.get(6)?,
|
||||||
|
share_mode: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_source_chat(&self, source_chat_id: i64, limit: usize, offset: usize) -> Result<Vec<ForwardDefinition>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
|
||||||
|
FROM forward_definitions WHERE source_chat_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let rows = stmt.query_map(params![source_chat_id, limit as i64, offset as i64], |row| {
|
||||||
|
Ok(ForwardDefinition {
|
||||||
|
id: row.get(0)?,
|
||||||
|
creator_user_id: row.get(1)?,
|
||||||
|
source_chat_id: row.get(2)?,
|
||||||
|
destination_chat_id: row.get(3)?,
|
||||||
|
review_group_id: row.get(4)?,
|
||||||
|
forward_message: row.get(5)?,
|
||||||
|
code: row.get(6)?,
|
||||||
|
share_mode: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
}).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE forward_definitions SET revoked_at = datetime('now') WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_allowed(&self, forward_id: i64, user_id: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let def: Option<ForwardDefinition> = conn.query_row(
|
||||||
|
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
|
||||||
|
FROM forward_definitions WHERE id = ?1",
|
||||||
|
params![forward_id],
|
||||||
|
|row| {
|
||||||
|
Ok(ForwardDefinition {
|
||||||
|
id: row.get(0)?,
|
||||||
|
creator_user_id: row.get(1)?,
|
||||||
|
source_chat_id: row.get(2)?,
|
||||||
|
destination_chat_id: row.get(3)?,
|
||||||
|
review_group_id: row.get(4)?,
|
||||||
|
forward_message: row.get(5)?,
|
||||||
|
code: row.get(6)?,
|
||||||
|
share_mode: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(def) = def {
|
||||||
|
if def.creator_user_id == user_id {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let list_entry: Option<String> = conn.query_row(
|
||||||
|
"SELECT list_type FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2",
|
||||||
|
params![forward_id, user_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
match def.share_mode.as_str() {
|
||||||
|
"w" => Ok(list_entry.map(|t| t == "allow").unwrap_or(false)),
|
||||||
|
_ => Ok(list_entry.map(|t| t != "block").unwrap_or(true)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_submission(&self, forward_id: i64, user_id: i64, content_id: &str) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO forward_submissions (forward_id, user_id, content_id) VALUES (?1, ?2, ?3)",
|
||||||
|
params![forward_id, user_id, content_id],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_submission(&self, id: i64) -> Result<Option<ForwardSubmission>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let row = conn.query_row(
|
||||||
|
"SELECT id, forward_id, user_id, content_id, status, review_message_id, created_at, resolved_at, resolver_id
|
||||||
|
FROM forward_submissions WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| {
|
||||||
|
Ok(ForwardSubmission {
|
||||||
|
id: row.get(0)?,
|
||||||
|
forward_id: row.get(1)?,
|
||||||
|
user_id: row.get(2)?,
|
||||||
|
content_id: ContentId::new_unchecked(row.get(3)?),
|
||||||
|
status: row.get(4)?,
|
||||||
|
review_message_id: row.get(5)?,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
resolved_at: row.get(7)?,
|
||||||
|
resolver_id: row.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_review_message_id(&self, id: i64, message_id: i32) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE forward_submissions SET review_message_id = ?1 WHERE id = ?2",
|
||||||
|
params![message_id, id],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_status(&self, id: i64, status: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE forward_submissions SET status = ?1, resolved_at = datetime('now') WHERE id = ?2",
|
||||||
|
params![status, id],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_definition(&self, id: i64) -> Result<Option<ForwardDefinition>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let row = conn.query_row(
|
||||||
|
"SELECT id, creator_user_id, source_chat_id, destination_chat_id, review_group_id, forward_message, code, share_mode, revoked_at, created_at
|
||||||
|
FROM forward_definitions WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| {
|
||||||
|
Ok(ForwardDefinition {
|
||||||
|
id: row.get(0)?,
|
||||||
|
creator_user_id: row.get(1)?,
|
||||||
|
source_chat_id: row.get(2)?,
|
||||||
|
destination_chat_id: row.get(3)?,
|
||||||
|
review_group_id: row.get(4)?,
|
||||||
|
forward_message: row.get(5)?,
|
||||||
|
code: row.get(6)?,
|
||||||
|
share_mode: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_to_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO forward_lists (forward_id, user_id, list_type) VALUES (?1, ?2, ?3) ON CONFLICT DO NOTHING",
|
||||||
|
params![forward_id, user_id, list_type],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_from_list(&self, forward_id: i64, user_id: i64, list_type: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM forward_lists WHERE forward_id = ?1 AND user_id = ?2 AND list_type = ?3",
|
||||||
|
params![forward_id, user_id, list_type],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct PunishmentRepo {
|
||||||
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PunishmentRepo {
|
||||||
|
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||||
|
Self { conn }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
&self,
|
||||||
|
chat_id: i64,
|
||||||
|
target_user_id: i64,
|
||||||
|
action_type: &str,
|
||||||
|
duration_seconds: Option<i64>,
|
||||||
|
reason: Option<&str>,
|
||||||
|
created_by: i64,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO punishments (chat_id, target_user_id, action_type, duration_seconds, reason, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![chat_id, target_user_id, action_type, duration_seconds, reason, created_by],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_active_for_chat_target(&self, chat_id: i64, target_user_id: i64, action_type: &str) -> Result<Vec<Punishment>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE chat_id = ?1 AND target_user_id = ?2 AND action_type = ?3 AND active = 1"
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let rows = stmt.query_map(params![chat_id, target_user_id, action_type], |row| {
|
||||||
|
Ok(Punishment {
|
||||||
|
id: row.get(0)?,
|
||||||
|
chat_id: row.get(1)?,
|
||||||
|
target_user_id: row.get(2)?,
|
||||||
|
action_type: row.get(3)?,
|
||||||
|
duration_seconds: row.get(4)?,
|
||||||
|
reason: row.get(5)?,
|
||||||
|
created_by: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
revoked_by: row.get(9)?,
|
||||||
|
active: row.get::<_, i64>(10)? != 0,
|
||||||
|
})
|
||||||
|
}).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let mut results = vec![];
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(&self, id: i64, revoked_by: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE punishments SET active = 0, revoked_at = datetime('now'), revoked_by = ?1 WHERE id = ?2",
|
||||||
|
params![revoked_by, id],
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_expired(&self) -> Result<Vec<Punishment>> {
|
||||||
|
let conn = self.conn.lock().await;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, chat_id, target_user_id, action_type, duration_seconds, reason, created_by, created_at, revoked_at, revoked_by, active FROM punishments WHERE active = 1 AND duration_seconds IS NOT NULL AND datetime(created_at, '+' || duration_seconds || ' seconds') <= datetime('now')"
|
||||||
|
).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(Punishment {
|
||||||
|
id: row.get(0)?,
|
||||||
|
chat_id: row.get(1)?,
|
||||||
|
target_user_id: row.get(2)?,
|
||||||
|
action_type: row.get(3)?,
|
||||||
|
duration_seconds: row.get(4)?,
|
||||||
|
reason: row.get(5)?,
|
||||||
|
created_by: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
revoked_at: row.get(8)?,
|
||||||
|
revoked_by: row.get(9)?,
|
||||||
|
active: row.get::<_, i64>(10)? != 0,
|
||||||
|
})
|
||||||
|
}).map_err(|e| CgcxError::Database(e.to_string()))?;
|
||||||
|
let mut results = vec![];
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use tower_http::{
|
|||||||
trace::TraceLayer,
|
trace::TraceLayer,
|
||||||
};
|
};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal;
|
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -68,9 +69,42 @@ struct VerifyPasswordRequest {
|
|||||||
password: String,
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct FileQuery {
|
struct FileQuery {
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "deserialize_download_bool")]
|
||||||
download: bool,
|
download: bool,
|
||||||
#[serde(rename = "sc", default)]
|
#[serde(rename = "sc", default)]
|
||||||
sc: Option<String>,
|
sc: Option<String>,
|
||||||
@@ -87,6 +121,11 @@ struct ByteRange {
|
|||||||
end: Option<u64>,
|
end: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuthSource {
|
||||||
|
Cookie,
|
||||||
|
QueryParam,
|
||||||
|
}
|
||||||
|
|
||||||
struct AppError(CgcxError);
|
struct AppError(CgcxError);
|
||||||
|
|
||||||
impl From<CgcxError> for AppError {
|
impl From<CgcxError> for AppError {
|
||||||
@@ -119,7 +158,57 @@ type AppResult<T> = Result<T, AppError>;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> cgcx_core::Result<()> {
|
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.
|
// Log panics so we can diagnose 500s that CatchPanicLayer swallows.
|
||||||
std::panic::set_hook(Box::new(|info| {
|
std::panic::set_hook(Box::new(|info| {
|
||||||
@@ -134,9 +223,6 @@ async fn main() -> cgcx_core::Result<()> {
|
|||||||
tracing::error!("PANIC at {}: {}", location, msg);
|
tracing::error!("PANIC at {}: {}", location, msg);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let config = Arc::new(Config::load()?);
|
|
||||||
config.validate()?;
|
|
||||||
|
|
||||||
let db_path = std::path::PathBuf::from(&config.database_path);
|
let db_path = std::path::PathBuf::from(&config.database_path);
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await.ok();
|
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/health", get(health))
|
||||||
.route("/api/content/:cxid", get(get_metadata))
|
.route("/api/content/:cxid", get(get_metadata))
|
||||||
.route("/api/content/:cxid/file/:file_idx", get(serve_file))
|
.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)
|
.merge(password_route)
|
||||||
.nest_service("/assets", static_service)
|
.nest_service("/assets", static_service)
|
||||||
.fallback(fallback)
|
.fallback(fallback)
|
||||||
@@ -299,7 +386,7 @@ async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Respons
|
|||||||
let headers = response.headers_mut();
|
let headers = response.headers_mut();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::CONTENT_SECURITY_POLICY,
|
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_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||||
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||||
@@ -328,19 +415,19 @@ fn password_from_request(
|
|||||||
cxid: &str,
|
cxid: &str,
|
||||||
password_hash: Option<&str>,
|
password_hash: Option<&str>,
|
||||||
cookie_secret: &[u8],
|
cookie_secret: &[u8],
|
||||||
) -> bool {
|
) -> Option<AuthSource> {
|
||||||
if let Some(sc) = query_sc {
|
if let Some(sc) = query_sc {
|
||||||
if let Some(hash) = password_hash {
|
if let Some(hash) = password_hash {
|
||||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
||||||
if Argon2::default().verify_password(sc.as_bytes(), &parsed_hash).is_ok() {
|
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)
|
.get_all(header::COOKIE)
|
||||||
.iter()
|
.iter()
|
||||||
.any(|v| {
|
.any(|v| {
|
||||||
@@ -351,6 +438,31 @@ fn password_from_request(
|
|||||||
})
|
})
|
||||||
}).unwrap_or(false)
|
}).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(
|
async fn get_metadata(
|
||||||
@@ -378,12 +490,17 @@ async fn get_metadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if content.password_hash.is_some() {
|
let auth_source = if content.password_hash.is_some() {
|
||||||
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
match password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
||||||
tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid);
|
Some(source) => Some(source),
|
||||||
return Err(CgcxError::Unauthorized.into());
|
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 file_repo = ContentFileRepo::new(state.db.conn());
|
||||||
let files = file_repo.list_by_content(&content_id).await?;
|
let files = file_repo.list_by_content(&content_id).await?;
|
||||||
@@ -403,11 +520,13 @@ async fn get_metadata(
|
|||||||
allow_download: content.allow_download,
|
allow_download: content.allow_download,
|
||||||
created_at: content.created_at.to_rfc3339(),
|
created_at: content.created_at.to_rfc3339(),
|
||||||
}).map_err(|_| CgcxError::BadRequest("json serialization".into()))?;
|
}).map_err(|_| CgcxError::BadRequest("json serialization".into()))?;
|
||||||
Ok(Response::builder()
|
let mut response = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.body(Body::from(body))
|
.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(
|
async fn verify_password(
|
||||||
@@ -451,6 +570,22 @@ async fn verify_password(
|
|||||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
.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(
|
async fn serve_file(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((cxid, file_idx)): Path<(String, u32)>,
|
Path((cxid, file_idx)): Path<(String, u32)>,
|
||||||
@@ -476,15 +611,22 @@ async fn serve_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if content.password_hash.is_some() {
|
let auth_source = if content.password_hash.is_some() {
|
||||||
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
match 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);
|
Some(source) => Some(source),
|
||||||
return Err(CgcxError::Unauthorized.into());
|
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 {
|
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());
|
return Err(CgcxError::Forbidden.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,26 +644,30 @@ async fn serve_file(
|
|||||||
} else {
|
} else {
|
||||||
format!("inline; filename=\"{}\"", sanitized_name)
|
format!("inline; filename=\"{}\"", sanitized_name)
|
||||||
};
|
};
|
||||||
return Ok(Response::builder()
|
let mut response = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
.header(header::ETAG, etag)
|
.header(header::ETAG, etag)
|
||||||
.header(header::CONTENT_LENGTH, "0")
|
.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())
|
.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
|
// Path traversal validation
|
||||||
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
||||||
.map_err(|e| {
|
.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())
|
CgcxError::Storage("invalid stored path".into())
|
||||||
})?;
|
})?;
|
||||||
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
||||||
tracing::error!("Path traversal blocked: {:?}", canonical_path);
|
let ip = client_ip_from_headers(&headers);
|
||||||
tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid);
|
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());
|
return Err(CgcxError::Forbidden.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,11 +676,13 @@ async fn serve_file(
|
|||||||
// If-None-Match check (skip increment)
|
// If-None-Match check (skip increment)
|
||||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
|
if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
|
||||||
if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) {
|
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)
|
.status(StatusCode::NOT_MODIFIED)
|
||||||
.header(header::ETAG, etag.clone())
|
.header(header::ETAG, etag.clone())
|
||||||
.body(Body::empty())
|
.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;
|
let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await;
|
||||||
}
|
}
|
||||||
repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?;
|
repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?;
|
||||||
return Ok(Response::builder()
|
let mut response = Response::builder()
|
||||||
.status(StatusCode::GONE)
|
.status(StatusCode::GONE)
|
||||||
.body(Body::empty())
|
.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_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
let body = Body::from_stream(body_stream);
|
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(
|
async fn stream_decrypted_file(
|
||||||
@@ -644,7 +927,7 @@ async fn stream_decrypted_file(
|
|||||||
master_key: Arc<MasterKey>,
|
master_key: Arc<MasterKey>,
|
||||||
wrapped_key: Vec<u8>,
|
wrapped_key: Vec<u8>,
|
||||||
tx: tokio::sync::mpsc::Sender<Result<Vec<u8>, std::io::Error>>,
|
tx: tokio::sync::mpsc::Sender<Result<Vec<u8>, std::io::Error>>,
|
||||||
_range: Option<ByteRange>,
|
range: Option<ByteRange>,
|
||||||
_file_size: u64,
|
_file_size: u64,
|
||||||
expected_hash: Vec<u8>,
|
expected_hash: Vec<u8>,
|
||||||
) -> cgcx_core::Result<()> {
|
) -> cgcx_core::Result<()> {
|
||||||
@@ -658,6 +941,92 @@ async fn stream_decrypted_file(
|
|||||||
let mut decrypt_stream = DecryptStream::new(&content_key, &header)?;
|
let mut decrypt_stream = DecryptStream::new(&content_key, &header)?;
|
||||||
|
|
||||||
let mut len_buf = [0u8; 4];
|
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;
|
let mut saw_final = false;
|
||||||
loop {
|
loop {
|
||||||
if file.read_exact(&mut len_buf).await.is_err() {
|
if file.read_exact(&mut len_buf).await.is_err() {
|
||||||
|
|||||||
134
docs/API.md
Normal file
134
docs/API.md
Normal 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
194
docs/AUTH_FLOW.md
Normal 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 client’s browser stores the cookie. Subsequent requests to the same domain automatically include it, so the `?sc=...` parameter is no longer needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Server Validates Passwords (Argon2)
|
||||||
|
|
||||||
|
Password verification happens in two places: `password_from_request` and the `verify_password` endpoint.
|
||||||
|
|
||||||
|
### Stored Hash Format
|
||||||
|
Password hashes are stored in the database as Argon2id hashes in the standard PHC string format (e.g., `$argon2id$v=19$m=65536,t=3,p=4$...`).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
```rust
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
|
||||||
|
let parsed_hash = PasswordHash::new(&hash)?;
|
||||||
|
let valid = Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok();
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses the default Argon2 parameters.
|
||||||
|
- If parsing the stored hash fails, verification is treated as failed.
|
||||||
|
- If verification fails, the server returns `401 Unauthorized`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Cookies Are Set After Successful Query-Param Auth
|
||||||
|
|
||||||
|
After a successful `?sc=...` authentication, the response includes:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Set-Cookie: cgcx_pw=<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
106
docs/COMMANDS.md
Normal 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
223
docs/FORWARD_SYSTEM.md
Normal 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
149
docs/MODERATION.md
Normal 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
137
docs/OPERATIONAL_NOTES.md
Normal 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.
|
||||||
240
frontend/package-lock.json
generated
240
frontend/package-lock.json
generated
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"marked": "^12.0.0"
|
"marked": "^12.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -467,6 +469,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -480,6 +491,15 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
@@ -500,6 +520,32 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -510,6 +556,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -537,6 +589,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.5",
|
"version": "3.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||||
@@ -546,6 +604,15 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@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": {
|
"node_modules/esm-env": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"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": "^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": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
@@ -614,6 +702,33 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -894,6 +1009,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -904,6 +1030,30 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/marked": {
|
||||||
"version": "12.0.2",
|
"version": "12.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
|
||||||
@@ -946,6 +1096,27 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -995,6 +1166,27 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||||
@@ -1029,6 +1221,18 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
"@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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1039,6 +1243,21 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.9",
|
"version": "5.55.9",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
|
||||||
@@ -1092,6 +1311,18 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.14",
|
"version": "8.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
"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": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"marked": "^12.0.0"
|
"marked": "^12.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
frontend/src/components/CodeViewer.svelte
Normal file
92
frontend/src/components/CodeViewer.svelte
Normal 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>
|
||||||
98
frontend/src/components/DocxViewer.svelte
Normal file
98
frontend/src/components/DocxViewer.svelte
Normal 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>
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
let { src, name } = $props()
|
let { src, name } = $props()
|
||||||
|
let failed = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="image-viewer">
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -19,4 +24,10 @@
|
|||||||
box-shadow: 6px 6px 0px var(--retro-shadow);
|
box-shadow: 6px 6px 0px var(--retro-shadow);
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
}
|
}
|
||||||
|
.image-error {
|
||||||
|
color: var(--retro-danger);
|
||||||
|
font-family: var(--retro-font, monospace);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script>
|
<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 ImageViewer from './ImageViewer.svelte'
|
||||||
import VideoPlayer from './VideoPlayer.svelte'
|
import VideoPlayer from './VideoPlayer.svelte'
|
||||||
import AudioPlayer from './AudioPlayer.svelte'
|
import AudioPlayer from './AudioPlayer.svelte'
|
||||||
import MarkdownRenderer from './MarkdownRenderer.svelte'
|
import MarkdownRenderer from './MarkdownRenderer.svelte'
|
||||||
import TextViewer from './TextViewer.svelte'
|
import TextViewer from './TextViewer.svelte'
|
||||||
|
import CodeViewer from './CodeViewer.svelte'
|
||||||
import DocumentCard from './DocumentCard.svelte'
|
import DocumentCard from './DocumentCard.svelte'
|
||||||
import ExecutableWarning from './ExecutableWarning.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()
|
let { files, cxid, password = '' } = $props()
|
||||||
|
|
||||||
@@ -17,7 +22,11 @@
|
|||||||
if (flags & 4) return 'audio'
|
if (flags & 4) return 'audio'
|
||||||
if (flags & 8) return 'markdown'
|
if (flags & 8) return 'markdown'
|
||||||
if (flags & 16) return 'text'
|
if (flags & 16) return 'text'
|
||||||
|
if (flags & 32) {
|
||||||
|
return file.mime === 'application/pdf' ? 'pdf' : 'docx'
|
||||||
|
}
|
||||||
if (flags & 64 || flags & 128) return 'dangerous'
|
if (flags & 64 || flags & 128) return 'dangerous'
|
||||||
|
if (flags & 512) return 'sensitive'
|
||||||
return 'document'
|
return 'document'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -39,9 +48,19 @@
|
|||||||
{:else if viewer === 'markdown'}
|
{:else if viewer === 'markdown'}
|
||||||
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
|
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
|
||||||
{:else if viewer === 'text'}
|
{: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'}
|
{:else if viewer === 'dangerous'}
|
||||||
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
|
{:else if viewer === 'sensitive'}
|
||||||
|
<SensitiveWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
{:else}
|
{:else}
|
||||||
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
23
frontend/src/components/PdfViewer.svelte
Normal file
23
frontend/src/components/PdfViewer.svelte
Normal 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>
|
||||||
56
frontend/src/components/SensitiveWarning.svelte
Normal file
56
frontend/src/components/SensitiveWarning.svelte
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
let { src } = $props()
|
let { src, rawUrl = '', fileName = '' } = $props()
|
||||||
let text = $state('')
|
let text = $state('')
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-viewer">
|
<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}
|
{#if loading}
|
||||||
<p>Loading text...</p>
|
<p>Loading text...</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -34,6 +40,33 @@
|
|||||||
border: 3px solid var(--retro-border);
|
border: 3px solid var(--retro-border);
|
||||||
box-shadow: 6px 6px 0px var(--retro-shadow);
|
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 {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
<div class="video-player">
|
<div class="video-player">
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video controls preload="metadata" {src}></video>
|
<video controls preload="metadata" {src}>
|
||||||
|
<source src={src} type={mime} />
|
||||||
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// "window.location.origin"
|
// "window.location.origin"
|
||||||
const API_BASE = "http://127.0.0.1:8090";
|
const API_BASE = "http://127.0.0.1:8090";
|
||||||
|
|
||||||
export async function fetchMetadata(cxid) {
|
export async function fetchMetadata(cxid, password = "") {
|
||||||
const res = await fetch(
|
let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}`;
|
||||||
`${API_BASE}/api/content/${encodeURIComponent(cxid)}`,
|
if (password) url += `?sc=${encodeURIComponent(password)}`;
|
||||||
);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(await res.text());
|
const err = new Error(await res.text());
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
@@ -34,6 +34,12 @@ export function fileUrl(cxid, fileIdx, download = false, password = "") {
|
|||||||
return url;
|
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) {
|
export function formatSize(bytes) {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
|||||||
16
frontend/src/lib/lang.js
Normal file
16
frontend/src/lib/lang.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -64,6 +64,13 @@
|
|||||||
<button onclick={submit} disabled={loading}>
|
<button onclick={submit} disabled={loading}>
|
||||||
{loading ? 'Loading...' : '[ Unlock ]'}
|
{loading ? 'Loading...' : '[ Unlock ]'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -135,4 +142,31 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: #777;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script>
|
<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 ImageViewer from '../components/ImageViewer.svelte'
|
||||||
import VideoPlayer from '../components/VideoPlayer.svelte'
|
import VideoPlayer from '../components/VideoPlayer.svelte'
|
||||||
import AudioPlayer from '../components/AudioPlayer.svelte'
|
import AudioPlayer from '../components/AudioPlayer.svelte'
|
||||||
import MarkdownRenderer from '../components/MarkdownRenderer.svelte'
|
import MarkdownRenderer from '../components/MarkdownRenderer.svelte'
|
||||||
import TextViewer from '../components/TextViewer.svelte'
|
import TextViewer from '../components/TextViewer.svelte'
|
||||||
|
import CodeViewer from '../components/CodeViewer.svelte'
|
||||||
import DocumentCard from '../components/DocumentCard.svelte'
|
import DocumentCard from '../components/DocumentCard.svelte'
|
||||||
import ExecutableWarning from '../components/ExecutableWarning.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'
|
import MixedGallery from '../components/MixedGallery.svelte'
|
||||||
|
|
||||||
let { cxid, sc } = $props()
|
let { cxid, sc } = $props()
|
||||||
@@ -28,28 +33,18 @@
|
|||||||
phase = 'loading_meta'
|
phase = 'loading_meta'
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const meta = await fetchMetadata(cxid)
|
const meta = await fetchMetadata(cxid, password)
|
||||||
metadata = meta
|
metadata = meta
|
||||||
if (meta.has_password && !password) {
|
phase = 'rendering'
|
||||||
|
} catch (e) {
|
||||||
|
const status = e.status || 0
|
||||||
|
if (status === 401) {
|
||||||
phase = 'password_required'
|
phase = 'password_required'
|
||||||
return
|
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'
|
phase = 'error'
|
||||||
const status = e.status || 0
|
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
error = '[ Not Found ] This content does not exist or has been removed.'
|
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) {
|
} else if (status === 429) {
|
||||||
error = '[ Rate Limited ] Too many requests. Please wait.'
|
error = '[ Rate Limited ] Too many requests. Please wait.'
|
||||||
} else if (status >= 500) {
|
} else if (status >= 500) {
|
||||||
@@ -65,6 +60,7 @@
|
|||||||
if (!password) return
|
if (!password) return
|
||||||
const ok = await verifyPassword(cxid, password)
|
const ok = await verifyPassword(cxid, password)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
metadata = await fetchMetadata(cxid, password)
|
||||||
phase = 'rendering'
|
phase = 'rendering'
|
||||||
} else {
|
} else {
|
||||||
error = 'Incorrect password.'
|
error = 'Incorrect password.'
|
||||||
@@ -83,8 +79,12 @@
|
|||||||
if (flags & 4) return 'audio'
|
if (flags & 4) return 'audio'
|
||||||
if (flags & 8) return 'markdown'
|
if (flags & 8) return 'markdown'
|
||||||
if (flags & 16) return 'text'
|
if (flags & 16) return 'text'
|
||||||
|
if (flags & 32) {
|
||||||
|
return file.mime === 'application/pdf' ? 'pdf' : 'docx'
|
||||||
|
}
|
||||||
if (flags & 64) return 'executable'
|
if (flags & 64) return 'executable'
|
||||||
if (flags & 128) return 'dangerous'
|
if (flags & 128) return 'dangerous'
|
||||||
|
if (flags & 512) return 'sensitive'
|
||||||
return 'document'
|
return 'document'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -133,9 +133,19 @@
|
|||||||
{:else if viewer === 'markdown'}
|
{:else if viewer === 'markdown'}
|
||||||
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
|
<MarkdownRenderer src={fileUrl(cxid, file.idx, false, password)} />
|
||||||
{:else if viewer === 'text'}
|
{: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'}
|
{:else if viewer === 'executable' || viewer === 'dangerous'}
|
||||||
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
<ExecutableWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
|
{:else if viewer === 'sensitive'}
|
||||||
|
<SensitiveWarning {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
{:else}
|
{:else}
|
||||||
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
<DocumentCard {file} downloadUrl={fileUrl(cxid, file.idx, true, password)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
37
migrations/003_forward_system.sql
Normal file
37
migrations/003_forward_system.sql
Normal 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)
|
||||||
|
);
|
||||||
15
migrations/004_punishments.sql
Normal file
15
migrations/004_punishments.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user