#![allow(clippy::result_large_err)]
use anyhow::{Context, Result};
use axum::extract::{Path as AxPath, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
pub struct ServeArgs {
pub workspace: PathBuf,
pub app: Option<String>,
pub branch: Option<String>,
pub releases: Option<PathBuf>,
pub port: u16,
}
struct ServeState {
root: PathBuf,
default_app: Option<String>,
releases: Option<PathBuf>,
}
type Shared = Arc<ServeState>;
fn ws(state: &ServeState) -> Result<cavs_workspace::Workspace, Response> {
cavs_workspace::Workspace::open(&state.root).map_err(|e| err(StatusCode::NOT_FOUND, e))
}
fn err(code: StatusCode, e: impl std::fmt::Display) -> Response {
(code, format!("{e}")).into_response()
}
pub fn serve(args: &ServeArgs) -> Result<()> {
let workspace = cavs_workspace::Workspace::open(&args.workspace)?;
let default_app = Some(workspace.app_id(args.app.as_deref())?);
let state: Shared = Arc::new(ServeState {
root: args.workspace.clone(),
default_app,
releases: args.releases.clone(),
});
let app = Router::new()
.route("/", get(index))
.route(
"/api/apps/{app}/branches/{branch}/current",
get(branch_current),
)
.route("/api/apps/{app}/builds/{build}", get(build_meta))
.route(
"/api/apps/{app}/builds/{build}/depots/{depot}/index",
get(depot_index),
)
.route(
"/api/apps/{app}/builds/{build}/depots/{depot}/files/{*path}",
get(depot_file),
)
.route(
"/api/apps/{app}/builds/{build}/depots/{depot}/chunks/{hash}",
get(depot_chunk),
)
.route("/api/assets/{asset}/{file}", get(release_file))
.route("/api/preview", get(preview))
.with_state(state);
eprintln!("+----------------------------------------------------------------+");
eprintln!("| WARNING: DEVELOPMENT SERVER ONLY |");
eprintln!("| No auth, no TLS, not production hardened. |");
eprintln!("| Never expose this to the internet or real players. |");
eprintln!("| Use cavs-server for real deployments. |");
eprintln!("+----------------------------------------------------------------+");
if let Some(branch) = &args.branch {
eprintln!("[serve] default branch: {branch}");
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", args.port))
.await
.with_context(|| format!("cannot bind 127.0.0.1:{}", args.port))?;
println!("listening on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
})
}
async fn index(State(state): State<Shared>) -> Response {
let app = state.default_app.clone().unwrap_or_default();
(
StatusCode::OK,
format!(
"cavs serve — DEVELOPMENT SERVER ONLY (no auth, no TLS, not production hardened)\n\
workspace: {}\n\
default app: {app}\n\n\
endpoints:\n\
GET /api/apps/{{app}}/branches/{{branch}}/current\n\
GET /api/apps/{{app}}/builds/{{build}}\n\
GET /api/apps/{{app}}/builds/{{build}}/depots/{{depot}}/index\n\
GET /api/apps/{{app}}/builds/{{build}}/depots/{{depot}}/files/{{path}} (Range supported)\n\
GET /api/apps/{{app}}/builds/{{build}}/depots/{{depot}}/chunks/{{hash}}\n\
GET /api/assets/{{asset}}/{{file}} (published release files)\n\
GET /api/preview?app=...&from=build_...&to=build_...\n",
state.root.display()
),
)
.into_response()
}
async fn branch_current(
State(state): State<Shared>,
AxPath((app, branch)): AxPath<(String, String)>,
) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
let meta = match ws.load_app(&app) {
Ok(a) => a,
Err(e) => return err(StatusCode::NOT_FOUND, e),
};
let b = match meta.branch(&branch) {
Ok(b) => b.clone(),
Err(e) => return err(StatusCode::NOT_FOUND, e),
};
let build = match &b.current_build {
Some(id) => match ws.build(&app, id) {
Ok(build) => Some(build),
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e),
},
None => None,
};
Json(serde_json::json!({ "branch": b, "build": build })).into_response()
}
async fn build_meta(
State(state): State<Shared>,
AxPath((app, build)): AxPath<(String, String)>,
) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
match ws.build(&app, &build) {
Ok(b) => Json(b).into_response(),
Err(e) => err(StatusCode::NOT_FOUND, e),
}
}
async fn depot_index(
State(state): State<Shared>,
AxPath((app, build, depot)): AxPath<(String, String, String)>,
) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
match ws.depot_index(&app, &build, &depot) {
Ok(idx) => Json(idx).into_response(),
Err(e) => err(StatusCode::NOT_FOUND, e),
}
}
fn depot_source(
ws: &cavs_workspace::Workspace,
app: &str,
build: &str,
depot: &str,
) -> Result<PathBuf, Response> {
let b = ws
.build(app, build)
.map_err(|e| err(StatusCode::NOT_FOUND, e))?;
let db = b
.depots
.iter()
.find(|d| d.depot_id == depot)
.ok_or_else(|| {
err(
StatusCode::NOT_FOUND,
format!("no depot '{depot}' in {build}"),
)
})?;
let root = PathBuf::from(&db.source_path);
if !root.exists() {
return Err(err(
StatusCode::GONE,
format!("depot source {} no longer exists", root.display()),
));
}
Ok(root)
}
fn safe_join(root: &std::path::Path, rel: &str) -> Result<PathBuf, Response> {
let bad = rel.starts_with('/')
|| rel.contains('\\')
|| rel.split('/').any(|seg| seg == ".." || seg.is_empty());
if bad {
return Err(err(
StatusCode::BAD_REQUEST,
"CAVS-E-ANALYZE-PATH-TRAVERSAL",
));
}
Ok(root.join(rel))
}
fn range_of(headers: &HeaderMap, len: u64) -> Option<(u64, u64)> {
let value = headers.get(header::RANGE)?.to_str().ok()?;
let spec = value.strip_prefix("bytes=")?;
let (start, end) = spec.split_once('-')?;
let start: u64 = start.parse().ok()?;
let end: u64 = if end.is_empty() {
len.saturating_sub(1)
} else {
end.parse().ok()?
};
(start <= end && end < len).then_some((start, end))
}
fn serve_bytes(headers: &HeaderMap, bytes: Vec<u8>) -> Response {
let len = bytes.len() as u64;
match range_of(headers, len) {
Some((start, end)) => {
let body = bytes[start as usize..=end as usize].to_vec();
(
StatusCode::PARTIAL_CONTENT,
[
(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}")),
(header::ACCEPT_RANGES, "bytes".into()),
],
body,
)
.into_response()
}
None => (
StatusCode::OK,
[(header::ACCEPT_RANGES, "bytes".to_string())],
bytes,
)
.into_response(),
}
}
async fn depot_file(
State(state): State<Shared>,
AxPath((app, build, depot, path)): AxPath<(String, String, String, String)>,
headers: HeaderMap,
) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
let root = match depot_source(&ws, &app, &build, &depot) {
Ok(r) => r,
Err(r) => return r,
};
let file = match safe_join(&root, &path) {
Ok(f) => f,
Err(r) => return r,
};
match std::fs::read(&file) {
Ok(bytes) => serve_bytes(&headers, bytes),
Err(e) => err(StatusCode::NOT_FOUND, e),
}
}
async fn depot_chunk(
State(state): State<Shared>,
AxPath((app, build, depot, hash)): AxPath<(String, String, String, String)>,
) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
let idx = match ws.depot_index(&app, &build, &depot) {
Ok(i) => i,
Err(e) => return err(StatusCode::NOT_FOUND, e),
};
let root = match depot_source(&ws, &app, &build, &depot) {
Ok(r) => r,
Err(r) => return r,
};
for (rel, chunks) in &idx.files {
let mut offset = 0u64;
for (h, len) in chunks {
if h == &hash {
let file = match safe_join(&root, rel) {
Ok(f) => f,
Err(r) => return r,
};
use std::io::{Read, Seek, SeekFrom};
let mut f = match std::fs::File::open(&file) {
Ok(f) => f,
Err(e) => return err(StatusCode::GONE, e),
};
let mut buf = vec![0u8; *len as usize];
if f.seek(SeekFrom::Start(offset)).is_err() || f.read_exact(&mut buf).is_err() {
return err(StatusCode::GONE, "chunk out of range (source changed?)");
}
if cavs_hash::to_hex(&cavs_hash::hash_chunk(&buf)) != hash {
return err(
StatusCode::GONE,
"CAVS-E-CHUNK-HASH-MISMATCH: source changed since indexing",
);
}
return (StatusCode::OK, buf).into_response();
}
offset += len;
}
}
err(
StatusCode::NOT_FOUND,
format!("chunk {hash} not in depot '{depot}'"),
)
}
async fn release_file(
State(state): State<Shared>,
AxPath((asset, file)): AxPath<(String, String)>,
headers: HeaderMap,
) -> Response {
let Some(releases) = &state.releases else {
return err(
StatusCode::NOT_FOUND,
"no --releases directory configured; run with --releases <dir>",
);
};
let root = releases.join(&asset);
let path = match safe_join(&root, &file) {
Ok(p) => p,
Err(r) => return r,
};
match std::fs::read(&path) {
Ok(bytes) => serve_bytes(&headers, bytes),
Err(e) => err(StatusCode::NOT_FOUND, e),
}
}
#[derive(Deserialize)]
struct PreviewParams {
app: Option<String>,
from: String,
to: String,
}
async fn preview(State(state): State<Shared>, Query(params): Query<PreviewParams>) -> Response {
let ws = match ws(&state) {
Ok(w) => w,
Err(r) => return r,
};
let app = params
.app
.or_else(|| state.default_app.clone())
.unwrap_or_default();
let from = match ws.build(&app, ¶ms.from) {
Ok(b) => b,
Err(e) => return err(StatusCode::NOT_FOUND, e),
};
let to = match ws.build(&app, ¶ms.to) {
Ok(b) => b,
Err(e) => return err(StatusCode::NOT_FOUND, e),
};
let mut per_depot: HashMap<String, u64> = HashMap::new();
for db in &to.depots {
let new_idx = match ws.depot_index(&app, &to.id, &db.depot_id) {
Ok(i) => i,
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let bytes = if from.depots.iter().any(|d| d.depot_id == db.depot_id) {
let old_idx = match ws.depot_index(&app, &from.id, &db.depot_id) {
Ok(i) => i,
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e),
};
cavs_workspace::sharing::fetch_bytes(&new_idx, &[&old_idx])
} else {
new_idx.total_bytes
};
per_depot.insert(db.depot_id.clone(), bytes);
}
let total: u64 = per_depot.values().sum();
Json(serde_json::json!({
"app": app,
"from": from.id,
"to": to.id,
"estimated_update_bytes_per_depot": per_depot,
"estimated_update_bytes_total": total,
"note": "raw new chunk bytes from content indices, before wire compression",
}))
.into_response()
}