use std::path::{Path, PathBuf};
use axum::Json;
use axum::body::Bytes;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use vti_common::audit::{AuditEvent, WebsiteFileDeletedData, WebsiteFileWrittenData};
use vti_common::auth::AdminAuth;
use crate::error::AppError;
use crate::server::AppState;
use crate::website::paths::{PathError, canonical_within_root};
use super::{WebsiteWriteResponse, require_website_config};
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
pub path: String,
pub size_bytes: u64,
pub etag: String,
pub modified_at: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListResponse {
pub items: Vec<FileEntry>,
pub next_cursor: Option<String>,
}
pub async fn list(
_admin: AdminAuth,
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<ListResponse>, AppError> {
let cfg = require_website_config(&state)?;
let root_dir = cfg.website.root_dir.clone().expect("guarded above");
let blocklist = cfg.website.executable_blocklist.clone();
let deploy_mode = cfg.website.deploy_mode.clone();
drop(cfg);
let serve_root = match deploy_mode.as_str() {
"managed" => root_dir.join("current"),
_ => root_dir,
};
let limit = query.limit.unwrap_or(50).clamp(1, 200) as usize;
let cursor = query.cursor.unwrap_or_default();
let mut entries = collect_entries(&serve_root, &blocklist)?;
entries.sort_by(|a, b| a.path.cmp(&b.path));
let start_idx = match entries.binary_search_by(|e| e.path.as_str().cmp(cursor.as_str())) {
Ok(i) => i + 1,
Err(i) => i,
};
let slice = entries
.into_iter()
.skip(start_idx)
.take(limit + 1)
.collect::<Vec<_>>();
let next_cursor = if slice.len() > limit {
Some(slice[limit - 1].path.clone())
} else {
None
};
let items: Vec<FileEntry> = slice.into_iter().take(limit).collect();
Ok(Json(ListResponse { items, next_cursor }))
}
fn collect_entries(root: &Path, blocklist: &[String]) -> Result<Vec<FileEntry>, AppError> {
use std::time::UNIX_EPOCH;
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let dir_entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in dir_entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_dir() {
stack.push(path);
continue;
}
if !meta.is_file() {
continue;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let dotted = format!(".{}", ext.to_ascii_lowercase());
if blocklist.iter().any(|b| b.eq_ignore_ascii_case(&dotted)) {
continue;
}
}
let rel = path.strip_prefix(root).unwrap_or(&path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
let etag = match std::fs::read(&path) {
Ok(bytes) => hex::encode(Sha256::digest(&bytes)),
Err(_) => continue,
};
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
out.push(FileEntry {
path: rel_str,
size_bytes: meta.len(),
etag,
modified_at,
});
}
}
Ok(out)
}
pub async fn show(
_admin: AdminAuth,
State(state): State<AppState>,
AxumPath(path): AxumPath<String>,
) -> Result<axum::response::Response, AppError> {
let resolved = resolve_or_400(&state, &path).await?;
let bytes = tokio::fs::read(&resolved)
.await
.map_err(|e| AppError::Internal(format!("read {resolved:?}: {e}")))?;
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&bytes)));
let mime = mime_guess::from_path(&resolved)
.first_or_octet_stream()
.to_string();
let resp = axum::response::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::ETAG, etag.clone())
.header("x-website-etag", etag)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(format!("build response: {e}")))?;
Ok(resp)
}
pub async fn write(
_admin: AdminAuth,
State(state): State<AppState>,
AxumPath(path): AxumPath<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<WebsiteWriteResponse>), AppError> {
let cfg = state.config.read().await;
let max_size = cfg.website.max_file_size_mb.saturating_mul(1024 * 1024);
let root_dir = cfg
.website
.root_dir
.clone()
.ok_or_else(|| AppError::Validation("website.root_dir is not configured".into()))?;
let blocklist = cfg.website.executable_blocklist.clone();
let deploy_mode = cfg.website.deploy_mode.clone();
drop(cfg);
if (body.len() as u64) > max_size {
return Err(AppError::Validation(format!(
"body size {} exceeds max_file_size_mb",
body.len()
)));
}
if deploy_mode == "managed" {
return Err(AppError::Validation(
"single-file writes are not supported in managed deploy mode; use POST /v1/website/deploy".into(),
));
}
let parent_for_path = root_dir.join(parent_dirs(&path));
if !parent_for_path.exists() {
std::fs::create_dir_all(&parent_for_path)
.map_err(|e| AppError::Internal(format!("mkdir -p {parent_for_path:?}: {e}")))?;
}
let req_path = format!("/{}", path.trim_start_matches('/'));
let _ = blocklist; let target = root_dir.join(req_path.trim_start_matches('/'));
let canon_parent = std::fs::canonicalize(&parent_for_path)
.map_err(|e| AppError::Validation(format!("parent path not resolvable: {e}")))?;
let canon_root = std::fs::canonicalize(&root_dir)
.map_err(|e| AppError::Internal(format!("canonicalize root: {e}")))?;
if !canon_parent.starts_with(&canon_root) {
return Err(AppError::Validation(
"write target escapes website.root_dir".into(),
));
}
if let Some(if_match) = headers.get(header::IF_MATCH).and_then(|v| v.to_str().ok()) {
let current = match tokio::fs::read(&target).await {
Ok(b) => Some(format!("\"{}\"", hex::encode(Sha256::digest(&b)))),
Err(_) => None,
};
let stripped = if_match.trim_matches('"');
let matches = current
.as_ref()
.map(|c| c.trim_matches('"') == stripped)
.unwrap_or(false);
if !matches {
return Err(AppError::Conflict(format!(
"If-Match {if_match} does not match the current ETag for {path}"
)));
}
}
let digest_hex = hex::encode(Sha256::digest(&body));
let etag = format!("\"{}\"", digest_hex);
let size_bytes = body.len() as u64;
let tmp = target.with_extension(format!(
"{}.tmp.{}",
target
.extension()
.and_then(|s| s.to_str())
.unwrap_or("file"),
rand_suffix(),
));
tokio::fs::write(&tmp, &body)
.await
.map_err(|e| AppError::Internal(format!("write tmp {tmp:?}: {e}")))?;
tokio::fs::rename(&tmp, &target)
.await
.map_err(|e| AppError::Internal(format!("rename {tmp:?} -> {target:?}: {e}")))?;
if let Some(writer) = state.audit_writer.as_ref() {
let _ = writer
.write(
"admin",
None,
AuditEvent::WebsiteFileWritten(WebsiteFileWrittenData {
path: path.clone(),
size_bytes,
sha256: digest_hex.clone(),
}),
)
.await;
}
Ok((
StatusCode::OK,
Json(WebsiteWriteResponse {
path,
etag,
size_bytes,
}),
))
}
pub async fn delete(
_admin: AdminAuth,
State(state): State<AppState>,
AxumPath(path): AxumPath<String>,
) -> Result<StatusCode, AppError> {
let resolved = resolve_or_400(&state, &path).await?;
tokio::fs::remove_file(&resolved)
.await
.map_err(|e| AppError::Internal(format!("delete {resolved:?}: {e}")))?;
if let Some(writer) = state.audit_writer.as_ref() {
let _ = writer
.write(
"admin",
None,
AuditEvent::WebsiteFileDeleted(WebsiteFileDeletedData { path: path.clone() }),
)
.await;
}
Ok(StatusCode::OK)
}
fn parent_dirs(path: &str) -> &str {
path.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("")
}
fn rand_suffix() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:x}")
}
async fn resolve_or_400(state: &AppState, path: &str) -> Result<PathBuf, AppError> {
let cfg = state.config.read().await;
let root_dir = cfg
.website
.root_dir
.clone()
.ok_or_else(|| AppError::Validation("website.root_dir is not configured".into()))?;
let blocklist = cfg.website.executable_blocklist.clone();
let deploy_mode = cfg.website.deploy_mode.clone();
drop(cfg);
let serve_root = match deploy_mode.as_str() {
"managed" => root_dir.join("current"),
_ => root_dir,
};
let req_path = format!("/{}", path.trim_start_matches('/'));
match canonical_within_root(&serve_root, &req_path, &blocklist) {
Ok(p) => Ok(p),
Err(PathError::NotFound) | Err(PathError::Hidden) => {
Err(AppError::NotFound(format!("no such file: {path}")))
}
Err(PathError::BlockedExtension(ext)) => Err(AppError::Forbidden(format!(
"extension {ext} is blocklisted"
))),
Err(_) => Err(AppError::Validation(format!(
"path rejected by website path-safety: {path}"
))),
}
}
#[allow(dead_code)]
fn _unused(_x: impl IntoResponse) {}