Initial commit
This commit is contained in:
38
crates/cgcx-server/Cargo.toml
Normal file
38
crates/cgcx-server/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "cgcx-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "cgcx-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
cgcx-core = { path = "../cgcx-core" }
|
||||
cgcx-config = { path = "../cgcx-config" }
|
||||
cgcx-db = { path = "../cgcx-db" }
|
||||
cgcx-storage = { path = "../cgcx-storage" }
|
||||
cgcx-crypto = { path = "../cgcx-crypto" }
|
||||
cgcx-content-typing = { path = "../cgcx-content-typing" }
|
||||
cgcx-moderation = { path = "../cgcx-moderation" }
|
||||
cgcx-file-pipeline = { path = "../cgcx-file-pipeline" }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-gzip", "catch-panic", "timeout"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.21"
|
||||
hex = "0.4"
|
||||
tokio-stream = "0.1"
|
||||
blake3 = "1.5"
|
||||
sodiumoxide = "0.2"
|
||||
tower_governor = "0.5"
|
||||
argon2 = "0.5"
|
||||
password-hash = "0.5"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
subtle = "2.5"
|
||||
1
crates/cgcx-server/src/lib.rs
Normal file
1
crates/cgcx-server/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub fn placeholder() {}
|
||||
713
crates/cgcx-server/src/main.rs
Normal file
713
crates/cgcx-server/src/main.rs
Normal file
@@ -0,0 +1,713 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use cgcx_config::Config;
|
||||
use cgcx_core::{ContentId, CgcxError};
|
||||
use cgcx_crypto::{unwrap_content_key, DecryptStream, MasterKey};
|
||||
use cgcx_db::{Database, ContentRepo, ContentFileRepo};
|
||||
use cgcx_storage::Storage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tower_http::{
|
||||
catch_panic::CatchPanicLayer,
|
||||
compression::CompressionLayer,
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
services::{ServeDir, ServeFile},
|
||||
timeout::TimeoutLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: Arc<Database>,
|
||||
storage: Arc<Storage>,
|
||||
config: Arc<Config>,
|
||||
master_key: Arc<MasterKey>,
|
||||
cookie_secret: Vec<u8>,
|
||||
allowed_roots: Arc<Vec<std::path::PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ContentMetadata {
|
||||
cxid: String,
|
||||
files: Vec<FileMetadata>,
|
||||
has_password: bool,
|
||||
max_views: Option<u64>,
|
||||
current_views: u64,
|
||||
allow_download: bool,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FileMetadata {
|
||||
idx: u32,
|
||||
name: String,
|
||||
mime: String,
|
||||
size: u64,
|
||||
render_flags: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VerifyPasswordRequest {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileQuery {
|
||||
#[serde(default)]
|
||||
download: bool,
|
||||
}
|
||||
|
||||
struct ByteRange {
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
}
|
||||
|
||||
struct AppError(CgcxError);
|
||||
|
||||
impl From<CgcxError> for AppError {
|
||||
fn from(e: CgcxError) -> Self {
|
||||
Self(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self.0 {
|
||||
CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
|
||||
CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
|
||||
CgcxError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.as_str()),
|
||||
CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"),
|
||||
CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
|
||||
};
|
||||
(status, msg.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> cgcx_core::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let config = Arc::new(Config::load()?);
|
||||
config.validate()?;
|
||||
|
||||
let db = Arc::new(Database::open("data/db.sqlite")?);
|
||||
db.run_migrations().await?;
|
||||
|
||||
let storage = Arc::new(Storage::new(config.storage.paths.clone()));
|
||||
storage.ensure_dirs().await?;
|
||||
|
||||
let master_key = match &config.crypto.aes_master_key_source {
|
||||
cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var)?,
|
||||
cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path)?,
|
||||
};
|
||||
master_key.log_startup(false);
|
||||
|
||||
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))?,
|
||||
]);
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
storage,
|
||||
config: config.clone(),
|
||||
master_key: Arc::new(master_key),
|
||||
cookie_secret,
|
||||
allowed_roots,
|
||||
};
|
||||
|
||||
let governor_conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.period(Duration::from_secs(60) / config.rate_limiting.requests_per_minute)
|
||||
.burst_size(config.rate_limiting.burst)
|
||||
.finish()
|
||||
.expect("invalid general rate limit config");
|
||||
|
||||
let password_governor_conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.period(Duration::from_secs(60) / config.rate_limiting.password_attempts_per_minute)
|
||||
.burst_size(3)
|
||||
.finish()
|
||||
.expect("invalid password rate limit config");
|
||||
|
||||
let password_route = Router::new()
|
||||
.route("/api/content/{cxid}/verify-password", post(verify_password))
|
||||
.layer(tower_governor::GovernorLayer {
|
||||
config: Arc::new(password_governor_conf),
|
||||
});
|
||||
|
||||
let static_service = ServeDir::new("frontend/dist")
|
||||
.fallback(ServeFile::new("frontend/dist/index.html"));
|
||||
|
||||
let base_url = config.server.base_url.clone();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _request_parts: &_| {
|
||||
if let Ok(origin_str) = origin.to_str() {
|
||||
if origin_str == base_url {
|
||||
return true;
|
||||
}
|
||||
// Allow localhost origins for development
|
||||
if origin_str.starts_with("http://127.0.0.1:")
|
||||
|| origin_str.starts_with("http://localhost:")
|
||||
|| origin_str.starts_with("https://127.0.0.1:")
|
||||
|| origin_str.starts_with("https://localhost:")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}))
|
||||
.allow_methods([Method::GET, Method::POST, Method::HEAD, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT, header::ACCEPT_ENCODING, header::RANGE])
|
||||
.allow_credentials(true)
|
||||
.max_age(Duration::from_secs(86400));
|
||||
|
||||
let compression = CompressionLayer::new().compress_when(|_status: axum::http::StatusCode, _version: axum::http::Version, headers: &axum::http::HeaderMap, _extensions: &axum::http::Extensions| {
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| {
|
||||
ct.starts_with("text/html")
|
||||
|| ct.starts_with("text/css")
|
||||
|| ct.starts_with("application/json")
|
||||
|| ct.starts_with("text/plain")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/content/{cxid}", get(get_metadata))
|
||||
.route("/api/content/{cxid}/file/{file_idx}", get(serve_file))
|
||||
.merge(password_route)
|
||||
.fallback_service(static_service)
|
||||
.layer(tower_governor::GovernorLayer {
|
||||
config: Arc::new(governor_conf),
|
||||
})
|
||||
.layer(compression)
|
||||
.layer(cors)
|
||||
.layer(axum::middleware::from_fn(security_headers))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(30),
|
||||
))
|
||||
.layer(CatchPanicLayer::new())
|
||||
.with_state(state.clone());
|
||||
|
||||
// Spawn background sweeper task
|
||||
let db_clone = state.db.clone();
|
||||
let storage_clone = state.storage.clone();
|
||||
let config_clone = (*state.config).clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(24 * 60 * 60));
|
||||
interval.tick().await; // skip immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
info!("Running daily orphan cleanup");
|
||||
let pipeline = cgcx_file_pipeline::FilePipeline::new(
|
||||
(*storage_clone).clone(),
|
||||
(*db_clone).clone(),
|
||||
config_clone.clone(),
|
||||
);
|
||||
if let Err(e) = pipeline.cleanup_orphans().await {
|
||||
warn!("Orphan cleanup failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Response {
|
||||
let mut response = next.run(req).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"),
|
||||
);
|
||||
headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"));
|
||||
headers.insert(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static("accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"),
|
||||
);
|
||||
headers.insert(
|
||||
header::STRICT_TRANSPORT_SECURITY,
|
||||
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
axum::Json(HealthResponse {
|
||||
status: "ok".into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(cxid): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
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());
|
||||
}
|
||||
|
||||
if let Some(max) = content.max_views {
|
||||
if content.view_count >= max {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
if content.password_hash.is_some() {
|
||||
let cookie_valid = headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.any(|v| {
|
||||
v.to_str().ok().map(|s| {
|
||||
s.split(';').any(|part| {
|
||||
let part = part.trim();
|
||||
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
|
||||
})
|
||||
}).unwrap_or(false)
|
||||
});
|
||||
if !cookie_valid {
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
}
|
||||
|
||||
let file_repo = ContentFileRepo::new(state.db.conn());
|
||||
let files = file_repo.list_by_content(&content_id).await?;
|
||||
|
||||
let body = serde_json::to_vec(&ContentMetadata {
|
||||
cxid: content.id.to_string(),
|
||||
files: files.into_iter().map(|f| FileMetadata {
|
||||
idx: f.file_index,
|
||||
name: f.original_name,
|
||||
mime: f.mime_type,
|
||||
size: f.size_bytes,
|
||||
render_flags: f.render_flags,
|
||||
}).collect(),
|
||||
has_password: content.password_hash.is_some(),
|
||||
max_views: content.max_views,
|
||||
current_views: content.view_count,
|
||||
allow_download: content.allow_download,
|
||||
created_at: content.created_at.to_rfc3339(),
|
||||
}).map_err(|e| CgcxError::BadRequest(format!("json serialization: {}", e)))?;
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn verify_password(
|
||||
State(state): State<AppState>,
|
||||
Path(cxid): Path<String>,
|
||||
Json(req): Json<VerifyPasswordRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
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)?;
|
||||
|
||||
let Some(hash) = content.password_hash else {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
let parsed_hash = PasswordHash::new(&hash)
|
||||
.map_err(|_| CgcxError::Crypto("invalid stored password hash".into()))?;
|
||||
let valid = Argon2::default()
|
||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
if !valid {
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
|
||||
let cookie_value = make_cookie_value(&cxid, &state.cookie_secret);
|
||||
let cookie = format!(
|
||||
"__Host-pw={}; Max-Age=3600; SameSite=Strict; Secure; HttpOnly; Path=/",
|
||||
cookie_value
|
||||
);
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header(header::SET_COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
State(state): State<AppState>,
|
||||
Path((cxid, file_idx)): Path<(String, u32)>,
|
||||
Query(query): Query<FileQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
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());
|
||||
}
|
||||
|
||||
if let Some(max) = content.max_views {
|
||||
if content.view_count >= max {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
if content.password_hash.is_some() {
|
||||
let cookie_valid = headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.any(|v| {
|
||||
v.to_str().ok().map(|s| {
|
||||
s.split(';').any(|part| {
|
||||
let part = part.trim();
|
||||
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
|
||||
})
|
||||
}).unwrap_or(false)
|
||||
});
|
||||
if !cookie_valid {
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
}
|
||||
|
||||
if query.download && !content.allow_download {
|
||||
return Err(CgcxError::Forbidden.into());
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
// Path traversal validation
|
||||
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
||||
.map_err(|e| {
|
||||
tracing::error!("canonicalize failed for {:?}: {}", file.stored_path, e);
|
||||
CgcxError::Storage("invalid stored path".into())
|
||||
})?;
|
||||
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
||||
tracing::error!("Path traversal blocked: {:?}", canonical_path);
|
||||
return Err(CgcxError::Forbidden.into());
|
||||
}
|
||||
|
||||
let etag = format!("\"{}\"", hex::encode(&file.encrypted_hash));
|
||||
|
||||
// If-None-Match check (skip increment)
|
||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
|
||||
if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::ETAG, etag.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Range header
|
||||
let range = if let Some(range_hdr) = headers.get(header::RANGE) {
|
||||
if let Some(hdr_str) = range_hdr.to_str().ok() {
|
||||
match parse_range(hdr_str, file.size_bytes) {
|
||||
Some(r) => Some(r),
|
||||
None => {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes))
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let is_range = range.is_some();
|
||||
let is_conditional = headers.contains_key(header::IF_NONE_MATCH);
|
||||
if !is_range && !is_conditional {
|
||||
let new_views = repo.increment_views(&content_id).await?;
|
||||
if let Some(max) = content.max_views {
|
||||
if new_views >= max {
|
||||
if !state.config.content.keep_content {
|
||||
for f in &files {
|
||||
if let Err(e) = tokio::fs::remove_file(&f.stored_path).await {
|
||||
tracing::warn!("failed to remove file {:?}: {}", f.stored_path, e);
|
||||
}
|
||||
}
|
||||
let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await;
|
||||
}
|
||||
repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_type = file.mime_type.clone();
|
||||
let sanitized_name = sanitize_content_disposition(&file.original_name);
|
||||
let disposition = if query.download && content.allow_download {
|
||||
format!("attachment; filename=\"{}\"", sanitized_name)
|
||||
} else {
|
||||
format!("inline; filename=\"{}\"", sanitized_name)
|
||||
};
|
||||
|
||||
let (status, content_length, content_range) = if let Some(ref r) = range {
|
||||
let end = r.end.unwrap_or(file.size_bytes - 1);
|
||||
let len = end - r.start + 1;
|
||||
let cr = format!("bytes {}-{}/{}", r.start, end, file.size_bytes);
|
||||
(StatusCode::PARTIAL_CONTENT, len, Some(cr))
|
||||
} else {
|
||||
(StatusCode::OK, file.size_bytes, None)
|
||||
};
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::ETAG, etag.clone())
|
||||
.header(header::CONTENT_LENGTH, content_length.to_string());
|
||||
|
||||
if file.mime_type.starts_with("video/") || file.mime_type.starts_with("audio/") {
|
||||
response = response.header(header::ACCEPT_RANGES, "bytes");
|
||||
}
|
||||
|
||||
if let Some(cr) = content_range {
|
||||
response = response.header(header::CONTENT_RANGE, cr);
|
||||
}
|
||||
|
||||
if content.password_hash.is_some() {
|
||||
response = response.header(header::CACHE_CONTROL, "private, no-store, max-age=0");
|
||||
} else {
|
||||
response = response.header(header::CACHE_CONTROL, "private, max-age=60");
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Vec<u8>, std::io::Error>>(4);
|
||||
|
||||
let path = file.stored_path.clone();
|
||||
let master_key = state.master_key.clone();
|
||||
let wrapped_key = file.encrypted_key_wrapped.clone();
|
||||
let expected_hash = file.encrypted_hash.clone();
|
||||
let file_size = file.size_bytes;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stream_decrypted_file(path, master_key, wrapped_key, tx, range, file_size, expected_hash).await {
|
||||
warn!("stream error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
let body = Body::from_stream(body_stream);
|
||||
|
||||
Ok(response.body(body).unwrap())
|
||||
}
|
||||
|
||||
async fn stream_decrypted_file(
|
||||
path: std::path::PathBuf,
|
||||
master_key: Arc<MasterKey>,
|
||||
wrapped_key: Vec<u8>,
|
||||
tx: tokio::sync::mpsc::Sender<Result<Vec<u8>, std::io::Error>>,
|
||||
_range: Option<ByteRange>,
|
||||
_file_size: u64,
|
||||
expected_hash: Vec<u8>,
|
||||
) -> cgcx_core::Result<()> {
|
||||
let mut file = tokio::fs::File::open(&path).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
|
||||
let mut header_buf = [0u8; 24];
|
||||
file.read_exact(&mut header_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
|
||||
|
||||
let content_key = unwrap_content_key(&wrapped_key, &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 len_buf = [0u8; 4];
|
||||
let mut saw_final = false;
|
||||
loop {
|
||||
if file.read_exact(&mut len_buf).await.is_err() {
|
||||
break; // EOF at message boundary
|
||||
}
|
||||
let msg_len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut msg_buf = vec![0u8; msg_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)) => {
|
||||
if tx.send(Ok(plaintext)).await.is_err() {
|
||||
return Ok(()); // client disconnected
|
||||
}
|
||||
if tag == TagFinal {
|
||||
saw_final = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_final {
|
||||
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "stream ended without Final tag"))).await;
|
||||
return Err(CgcxError::Crypto("stream ended without Final tag".into()));
|
||||
}
|
||||
|
||||
let computed_hash = decrypt_stream.finalize().to_vec();
|
||||
if computed_hash != expected_hash {
|
||||
tracing::error!(target: "critical", "BLAKE3 integrity mismatch for file {:?}: expected {} got {}", path, hex::encode(&expected_hash), hex::encode(&computed_hash));
|
||||
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "integrity check failed"))).await;
|
||||
return Err(CgcxError::Crypto("BLAKE3 integrity mismatch".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_range(range_hdr: &str, file_size: u64) -> Option<ByteRange> {
|
||||
const PREFIX: &str = "bytes=";
|
||||
if !range_hdr.starts_with(PREFIX) {
|
||||
return None;
|
||||
}
|
||||
let rest = &range_hdr[PREFIX.len()..];
|
||||
// Basic version: only single-byte range
|
||||
if rest.contains(',') {
|
||||
return None;
|
||||
}
|
||||
let mut parts = rest.splitn(2, '-');
|
||||
let start_str = parts.next()?.trim();
|
||||
let end_str = parts.next()?.trim();
|
||||
|
||||
if start_str.is_empty() && end_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if start_str.is_empty() {
|
||||
let suffix_len: u64 = end_str.parse().ok()?;
|
||||
let start = file_size.saturating_sub(suffix_len);
|
||||
Some(ByteRange {
|
||||
start,
|
||||
end: Some(file_size.saturating_sub(1)),
|
||||
})
|
||||
} else if end_str.is_empty() {
|
||||
let start: u64 = start_str.parse().ok()?;
|
||||
if start >= file_size {
|
||||
return None;
|
||||
}
|
||||
Some(ByteRange { start, end: None })
|
||||
} else {
|
||||
let start: u64 = start_str.parse().ok()?;
|
||||
let end: u64 = end_str.parse().ok()?;
|
||||
if start > end || start >= file_size {
|
||||
return None;
|
||||
}
|
||||
let end = end.min(file_size - 1);
|
||||
Some(ByteRange { start, end: Some(end) })
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_content_disposition(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.map(|c| match c {
|
||||
'\\' => "\\\\".to_string(),
|
||||
'"' => "\\\"".to_string(),
|
||||
c => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
fn hmac_cookie(cxid: &str, secret: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
|
||||
mac.update(cxid.as_bytes());
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn make_cookie_value(cxid: &str, secret: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
let mac = hmac_cookie(cxid, secret);
|
||||
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)
|
||||
}
|
||||
|
||||
fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool {
|
||||
use base64::Engine;
|
||||
let decoded = match base64::engine::general_purpose::STANDARD.decode(cookie_value) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut parts = decoded.splitn(2, |&b| b == b':');
|
||||
let decoded_cxid = match parts.next() {
|
||||
Some(p) => match std::str::from_utf8(p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
},
|
||||
None => return false,
|
||||
};
|
||||
let mac_bytes = match parts.next() {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
if decoded_cxid != cxid {
|
||||
return false;
|
||||
}
|
||||
let expected = hmac_cookie(cxid, secret);
|
||||
if mac_bytes.len() != expected.len() {
|
||||
return false;
|
||||
}
|
||||
use subtle::ConstantTimeEq;
|
||||
mac_bytes.ct_eq(&expected).into()
|
||||
}
|
||||
Reference in New Issue
Block a user