Major improvement, security handling, file handling +fixes

This commit is contained in:
unknown
2026-05-23 00:13:56 +02:00
parent 2129081599
commit a7b44af91a
25 changed files with 925 additions and 116 deletions

View File

@@ -9,5 +9,6 @@ cgcx-config = { path = "../cgcx-config" }
chrono = { version = "0.4", features = ["serde"] }
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
rusqlite_migration = "1.3"
serde_json = "1.0"
tokio = { version = "1", features = ["sync", "rt"] }
tracing = "0.1"

View File

@@ -49,6 +49,9 @@ impl Database {
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")),
rusqlite_migration::M::up(include_str!("../../../migrations/005_show_author.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/006_dedup.sql")),
rusqlite_migration::M::up(include_str!("../../../migrations/007_hash_blacklist.sql")),
]);
migrations.to_latest(&mut *conn)
.map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?;

View File

@@ -12,16 +12,43 @@ impl UserRepo {
Self { conn }
}
pub async fn ensure_exists(&self, id: i64, username: Option<&str>, first_name: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO users (id, telegram_username, first_name) VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET telegram_username=excluded.telegram_username, first_name=excluded.first_name",
params![id, username, first_name],
).map_err(|e| CgcxError::Database(e.to_string()))?;
pub async fn ensure_exists(&self, id: i64, username: Option<&str>, first_name: &str, chat_id: i64, uname_changes_path: Option<&str>) -> Result<()> {
let old_username = {
let conn = self.conn.lock().await;
let old: Option<String> = conn.query_row(
"SELECT telegram_username FROM users WHERE id = ?1",
params![id],
|row| row.get(0),
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
conn.execute(
"INSERT INTO users (id, telegram_username, first_name) VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET telegram_username=excluded.telegram_username, first_name=excluded.first_name",
params![id, username, first_name],
).map_err(|e| CgcxError::Database(e.to_string()))?;
old
};
if let (Some(path), Some(ref old)) = (uname_changes_path, old_username) {
if old.as_str() != username.unwrap_or("") {
Self::log_username_change(id, chat_id, Some(old.as_str()), username, path);
}
}
Ok(())
}
fn log_username_change(user_id: i64, chat_id: i64, old: Option<&str>, new: Option<&str>, path: &str) {
let entry = serde_json::json!({
"timestamp": chrono::Utc::now().to_rfc3339(),
"user_id": user_id,
"old_username": old.unwrap_or(""),
"new_username": new.unwrap_or(""),
"chat_id": chat_id
});
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
use std::io::Write;
let _ = writeln!(file, "{}", entry);
}
}
pub async fn get(&self, id: i64) -> Result<Option<User>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
@@ -78,8 +105,8 @@ impl ContentRepo {
let conn = self.conn.lock().await;
let status = format!("{:?}", content.status).to_lowercase();
conn.execute(
"INSERT INTO contents (id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"INSERT INTO contents (id, user_id, status, view_count, max_views, allow_download, password_hash, show_author, created_at, deleted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(id) DO NOTHING",
params![
content.id.as_str(),
@@ -89,6 +116,7 @@ impl ContentRepo {
content.max_views.map(|v| v as i64),
content.allow_download as i64,
content.password_hash.as_ref(),
content.show_author as i64,
content.created_at,
content.deleted_at,
],
@@ -99,7 +127,7 @@ impl ContentRepo {
pub async fn get(&self, id: &ContentId) -> Result<Option<Content>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, show_author, created_at, deleted_at
FROM contents WHERE id = ?1",
params![id.as_str()],
|row| {
@@ -117,8 +145,9 @@ impl ContentRepo {
max_views: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
allow_download: row.get::<_, i64>(5)? != 0,
password_hash: row.get(6)?,
created_at: row.get(7)?,
deleted_at: row.get(8)?,
show_author: row.get::<_, i64>(7)? != 0,
created_at: row.get(8)?,
deleted_at: row.get(9)?,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
@@ -128,7 +157,7 @@ impl ContentRepo {
pub async fn list_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Content>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at
"SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, show_author, created_at, deleted_at
FROM contents WHERE user_id = ?1 AND status != 'deleted' ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![user_id, limit as i64, offset as i64], |row| {
@@ -146,8 +175,9 @@ impl ContentRepo {
max_views: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
allow_download: row.get::<_, i64>(5)? != 0,
password_hash: row.get(6)?,
created_at: row.get(7)?,
deleted_at: row.get(8)?,
show_author: row.get::<_, i64>(7)? != 0,
created_at: row.get(8)?,
deleted_at: row.get(9)?,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
@@ -220,8 +250,8 @@ impl ContentFileRepo {
pub async fn insert(&self, file: &ContentFile) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO content_files (content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
"INSERT INTO content_files (content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at, plaintext_hash, ref_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(content_id, file_index) DO NOTHING",
params![
file.content_id.as_str(),
@@ -235,6 +265,8 @@ impl ContentFileRepo {
&file.encrypted_hash,
file.render_flags as i64,
file.created_at,
&file.plaintext_hash,
file.ref_count as i64,
],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
@@ -243,7 +275,7 @@ impl ContentFileRepo {
pub async fn list_by_content(&self, content_id: &ContentId) -> Result<Vec<ContentFile>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at
"SELECT content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at, plaintext_hash, ref_count
FROM content_files WHERE content_id = ?1 ORDER BY file_index"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map(params![content_id.as_str()], |row| {
@@ -259,6 +291,8 @@ impl ContentFileRepo {
encrypted_hash: row.get(8)?,
render_flags: row.get::<_, i64>(9)? as u32,
created_at: row.get(10)?,
plaintext_hash: row.get(11)?,
ref_count: row.get::<_, i64>(12)? as u64,
})
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
@@ -284,6 +318,100 @@ impl ContentFileRepo {
}
Ok(out)
}
pub async fn find_active_by_plaintext_hash(&self, hash: &[u8]) -> Result<Option<ContentFile>> {
let conn = self.conn.lock().await;
let row = conn.query_row(
"SELECT cf.content_id, cf.file_index, cf.original_name, cf.stored_path, cf.mime_type, cf.size_bytes, cf.ciphertext_size_bytes, cf.encrypted_key_wrapped, cf.encrypted_hash, cf.render_flags, cf.created_at, cf.plaintext_hash, cf.ref_count
FROM content_files cf
JOIN contents c ON c.id = cf.content_id
WHERE cf.plaintext_hash = ?1 AND c.status NOT IN ('deleted', 'blacklisted')
LIMIT 1",
params![hash],
|row| {
Ok(ContentFile {
content_id: ContentId::new_unchecked(row.get(0)?),
file_index: row.get::<_, i64>(1)? as u32,
original_name: row.get(2)?,
stored_path: std::path::PathBuf::from(row.get::<_, String>(3)?),
mime_type: row.get(4)?,
size_bytes: row.get::<_, i64>(5)? as u64,
ciphertext_size_bytes: row.get::<_, i64>(6)? as u64,
encrypted_key_wrapped: row.get(7)?,
encrypted_hash: row.get(8)?,
render_flags: row.get::<_, i64>(9)? as u32,
created_at: row.get(10)?,
plaintext_hash: row.get(11)?,
ref_count: row.get::<_, i64>(12)? as u64,
})
},
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(row)
}
pub async fn increment_ref_count(&self, content_id: &ContentId, file_index: u32) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE content_files SET ref_count = ref_count + 1 WHERE content_id = ?1 AND file_index = ?2",
params![content_id.as_str(), file_index as i64],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn decrement_ref_count(&self, content_id: &ContentId, file_index: u32) -> Result<u64> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE content_files SET ref_count = ref_count - 1 WHERE content_id = ?1 AND file_index = ?2",
params![content_id.as_str(), file_index as i64],
).map_err(|e| CgcxError::Database(e.to_string()))?;
let count: i64 = conn.query_row(
"SELECT ref_count FROM content_files WHERE content_id = ?1 AND file_index = ?2",
params![content_id.as_str(), file_index as i64],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(count as u64)
}
pub async fn decrement_ref_count_for_path(&self, stored_path: &std::path::Path) -> Result<Option<u64>> {
let mut conn = self.conn.lock().await;
let tx = conn.transaction().map_err(|e| CgcxError::Database(e.to_string()))?;
let existing: Option<i64> = tx.query_row(
"SELECT ref_count FROM content_files WHERE stored_path = ?1 AND ref_count > 0 LIMIT 1",
params![stored_path.to_str()],
|row| row.get(0),
).optional().map_err(|e| CgcxError::Database(e.to_string()))?;
if let Some(rc) = existing {
tx.execute(
"UPDATE content_files SET ref_count = ref_count - 1 WHERE stored_path = ?1 AND ref_count > 0",
params![stored_path.to_str()],
).map_err(|e| CgcxError::Database(e.to_string()))?;
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(Some((rc - 1) as u64))
} else {
tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(None)
}
}
pub async fn count_by_path_excluding_content(&self, stored_path: &std::path::Path, content_id: &ContentId) -> Result<usize> {
let conn = self.conn.lock().await;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM content_files WHERE stored_path = ?1 AND content_id != ?2",
params![stored_path.to_str(), content_id.as_str()],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(count as usize)
}
pub async fn count_by_path(&self, stored_path: &std::path::Path) -> Result<usize> {
let conn = self.conn.lock().await;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM content_files WHERE stored_path = ?1",
params![stored_path.to_str()],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(count as usize)
}
}
pub struct ReportRepo {
@@ -607,6 +735,25 @@ impl ForwardRepo {
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn list_active_chat_ids(&self) -> Result<Vec<i64>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT DISTINCT source_chat_id FROM forward_definitions WHERE revoked_at IS NULL
UNION
SELECT DISTINCT destination_chat_id FROM forward_definitions WHERE revoked_at IS NULL
UNION
SELECT DISTINCT review_group_id FROM forward_definitions WHERE revoked_at IS NULL"
).map_err(|e| CgcxError::Database(e.to_string()))?;
let rows = stmt.query_map([], |row| {
row.get::<_, i64>(0)
}).map_err(|e| CgcxError::Database(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?);
}
Ok(out)
}
}
pub struct PunishmentRepo {
conn: Arc<Mutex<Connection>>,
@@ -697,3 +844,32 @@ impl PunishmentRepo {
Ok(results)
}
}
pub struct HashBlacklistRepo {
conn: Arc<Mutex<Connection>>,
}
impl HashBlacklistRepo {
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
Self { conn }
}
pub async fn insert(&self, hash: &[u8], reason: Option<&str>) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT INTO hash_blacklist (hash, reason) VALUES (?1, ?2) ON CONFLICT(hash) DO NOTHING",
params![hash, reason],
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(())
}
pub async fn contains(&self, hash: &[u8]) -> Result<bool> {
let conn = self.conn.lock().await;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM hash_blacklist WHERE hash = ?1",
params![hash],
|row| row.get(0),
).map_err(|e| CgcxError::Database(e.to_string()))?;
Ok(count > 0)
}
}