V0.1.1 release, close to actual release. Bug & security fixes/improvements.

This commit is contained in:
unknown
2026-05-24 19:29:41 +02:00
parent a7b44af91a
commit b004e15948
38 changed files with 3145 additions and 137 deletions

View File

@@ -37,3 +37,4 @@ password-hash = "0.5"
hmac = "0.12"
sha2 = "0.10"
subtle = "2.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -10,7 +10,7 @@ use axum::{
use cgcx_config::Config;
use cgcx_core::{ContentId, CgcxError};
use cgcx_crypto::{unwrap_content_key, DecryptStream, MasterKey};
use cgcx_db::{Database, ContentRepo, ContentFileRepo, UserRepo};
use cgcx_db::{Database, ContentRepo, ContentFileRepo, UserRepo, ReportRepo};
use cgcx_storage::Storage;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
@@ -37,6 +37,7 @@ struct AppState {
master_key: Arc<MasterKey>,
cookie_secret: Vec<u8>,
allowed_roots: Arc<Vec<std::path::PathBuf>>,
http_client: reqwest::Client,
}
#[derive(Serialize)]
@@ -77,6 +78,11 @@ struct VerifyPasswordRequest {
password: String,
}
#[derive(Deserialize)]
struct ReportRequest {
reason: String,
}
fn deserialize_download_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
@@ -239,6 +245,12 @@ async fn main() -> cgcx_core::Result<()> {
info!("Server database opened at: {:?}", std::fs::canonicalize(&db_path).unwrap_or_else(|_| db_path.clone()));
db.run_migrations().await?;
// Seed a dummy web-reporter user so web-submitted reports satisfy the FK constraint.
let user_repo = UserRepo::new(db.conn());
if let Err(e) = user_repo.ensure_exists(0, Some("web"), "Web Reporter", 0, None).await {
tracing::warn!("Failed to seed web reporter user: {}", e);
}
let storage = Arc::new(Storage::new(config.storage.paths.clone()));
storage.ensure_dirs().await?;
@@ -251,12 +263,14 @@ async fn main() -> cgcx_core::Result<()> {
let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec();
let allowed_roots = Arc::new(vec![
tokio::fs::canonicalize(&config.storage.paths.media).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.documents).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.text).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.temp).await.map_err(|e| CgcxError::Io(e))?,
tokio::fs::canonicalize(&config.storage.paths.media).await.map_err(CgcxError::Io)?,
tokio::fs::canonicalize(&config.storage.paths.documents).await.map_err(CgcxError::Io)?,
tokio::fs::canonicalize(&config.storage.paths.text).await.map_err(CgcxError::Io)?,
tokio::fs::canonicalize(&config.storage.paths.temp).await.map_err(CgcxError::Io)?,
]);
let http_client = reqwest::Client::new();
let state = AppState {
db,
storage,
@@ -264,6 +278,7 @@ async fn main() -> cgcx_core::Result<()> {
master_key: Arc::new(master_key),
cookie_secret,
allowed_roots,
http_client,
};
let mut governor_builder = tower_governor::governor::GovernorConfigBuilder::default();
@@ -331,6 +346,7 @@ async fn main() -> cgcx_core::Result<()> {
.route("/api/content/:cxid", get(get_metadata))
.route("/api/content/:cxid/file/:file_idx", get(serve_file))
.route("/api/content/:cxid/file/:file_idx/raw", get(serve_raw_file))
.route("/api/content/:cxid/report", post(report_content))
.merge(password_route)
.nest_service("/assets", static_service)
.fallback(fallback)
@@ -371,8 +387,8 @@ async fn main() -> cgcx_core::Result<()> {
let addr = format!("{}:{}", config.server.bind_address, config.server.port);
info!("Server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| CgcxError::Io(e))?;
axum::serve(listener, app).await.map_err(|e| CgcxError::Io(e))?;
let listener = tokio::net::TcpListener::bind(&addr).await.map_err(CgcxError::Io)?;
axum::serve(listener, app).await.map_err(CgcxError::Io)?;
Ok(())
}
@@ -610,11 +626,83 @@ fn client_ip_from_headers(headers: &HeaderMap) -> String {
"unknown".to_string()
}
async fn report_content(
State(state): State<AppState>,
Path(cxid): Path<String>,
Json(req): Json<ReportRequest>,
) -> AppResult<impl IntoResponse> {
tracing::info!("report_content: cxid={}", cxid);
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 {
return Err(CgcxError::NotFound.into());
}
let file_repo = ContentFileRepo::new(state.db.conn());
let files = file_repo.list_by_content(&content_id).await?;
let file_count = files.len();
let report_repo = ReportRepo::new(state.db.conn());
let report_id = report_repo.insert(&content_id, 0, &req.reason).await?;
let bot_token = &state.config.telegram.bot_token;
let api_base = state.config.telegram.api_url.as_deref().unwrap_or("https://api.telegram.org");
let report_text = format!(
"<b>[ NEW REPORT ]</b> #{}\n\nCXID: <code>{}</code>\nReporter: <i>web</i>\nOwner: <code>{}</code>\nUploaded: <i>{}</i>\nFiles: <b>{}</b>",
report_id,
cxid,
content.user_id,
content.created_at.format("%Y-%m-%d %H:%M"),
file_count
);
let keyboard = serde_json::json!({
"inline_keyboard": [
[
{"text": "[ Rmv + Ban ]", "callback_data": format!("v1:admin:delblk:{}", report_id)},
{"text": "[ Delete Only ]", "callback_data": format!("v1:admin:del:{}", report_id)}
],
[
{"text": "[ Blacklist Only ]", "callback_data": format!("v1:admin:blk:{}", report_id)},
{"text": "[ Ignore ]", "callback_data": format!("v1:admin:ign:{}", report_id)}
]
]
});
for &group_id in &state.config.groups.review_group_ids {
let url = format!("{}/bot{}/sendMessage", api_base, bot_token);
let payload = serde_json::json!({
"chat_id": group_id,
"text": report_text,
"parse_mode": "HTML",
"reply_markup": keyboard
});
match state.http_client.post(&url).json(&payload).send().await {
Ok(resp) => {
if !resp.status().is_success() {
tracing::warn!("Failed to send report notification to group {}: HTTP {}", group_id, resp.status());
}
}
Err(e) => {
tracing::warn!("Failed to send report notification to group {}: {}", group_id, e);
}
}
}
Ok(StatusCode::NO_CONTENT)
}
async fn serve_file(
State(state): State<AppState>,
Path((cxid, file_idx)): Path<(String, u32)>,
Query(query): Query<FileQuery>,
headers: HeaderMap,
method: Method,
) -> AppResult<impl IntoResponse> {
tracing::info!("serve_file: cxid={} file_idx={}", cxid, file_idx);
let content_id = ContentId::try_from(cxid.as_str())?;
@@ -712,7 +800,7 @@ async fn serve_file(
// Parse Range header
let range = if let Some(range_hdr) = headers.get(header::RANGE) {
if let Some(hdr_str) = range_hdr.to_str().ok() {
if let Ok(hdr_str) = range_hdr.to_str() {
match parse_range(hdr_str, file.size_bytes) {
Some(r) => Some(r),
None => {
@@ -732,7 +820,8 @@ async fn serve_file(
let is_range = range.is_some();
let is_conditional = headers.contains_key(header::IF_NONE_MATCH);
if !is_range && !is_conditional {
let is_head = method == Method::HEAD;
if !is_range && !is_conditional && !is_head {
let new_views = repo.increment_views(&content_id).await?;
if let Some(max) = content.max_views {
if new_views >= max {
@@ -750,10 +839,8 @@ async fn serve_file(
if let Err(e) = file_repo.decrement_ref_count(&f.content_id, f.file_index).await {
tracing::warn!("failed to decrement ref_count: {}", e);
}
} else {
if let Err(e) = file_repo.decrement_ref_count_for_path(&f.stored_path).await {
tracing::warn!("failed to decrement owner ref_count: {}", e);
}
} else if let Err(e) = file_repo.decrement_ref_count_for_path(&f.stored_path).await {
tracing::warn!("failed to decrement owner ref_count: {}", e);
}
let remaining = file_repo.count_by_path_excluding_content(&f.stored_path, &f.content_id).await.unwrap_or(1);
if remaining == 0 {
@@ -836,6 +923,7 @@ async fn serve_raw_file(
Path((cxid, file_idx)): Path<(String, u32)>,
Query(query): Query<ScQuery>,
headers: HeaderMap,
method: Method,
) -> AppResult<impl IntoResponse> {
tracing::info!("serve_raw_file: cxid={} file_idx={}", cxid, file_idx);
let content_id = ContentId::try_from(cxid.as_str())?;
@@ -901,6 +989,44 @@ async fn serve_raw_file(
return Err(CgcxError::Forbidden.into());
}
let is_head = method == Method::HEAD;
if !is_head {
let new_views = repo.increment_views(&content_id).await?;
if let Some(max) = content.max_views {
if new_views >= max {
let db = state.db.clone();
let storage = state.storage.clone();
let content_id = content_id.clone();
let files = files.clone();
let keep_content = state.config.content.keep_content;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
if !keep_content {
let file_repo = ContentFileRepo::new(db.conn());
for f in &files {
if f.ref_count > 0 {
if let Err(e) = file_repo.decrement_ref_count(&f.content_id, f.file_index).await {
tracing::warn!("failed to decrement ref_count: {}", e);
}
} else if let Err(e) = file_repo.decrement_ref_count_for_path(&f.stored_path).await {
tracing::warn!("failed to decrement owner ref_count: {}", e);
}
let remaining = file_repo.count_by_path_excluding_content(&f.stored_path, &f.content_id).await.unwrap_or(1);
if remaining == 0 {
if let Err(e) = tokio::fs::remove_file(&f.stored_path).await {
tracing::warn!("failed to remove file {:?}: {}", f.stored_path, e);
}
}
}
let _ = storage.delete_content_files(&content_id, "application/octet-stream").await;
}
let repo = ContentRepo::new(db.conn());
let _ = repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await;
});
}
}
}
// 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];