use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use vti_common::audit::{AuditEvent, WebsiteFileDeletedData, WebsiteFileWrittenData};
use crate::error::AppError;
use crate::server::AppState;
use crate::website::paths::{PathError, canonical_within_root, canonical_within_root_for_create};
use super::{WebsiteWriteResponse, require_website_config};
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
pub path: String,
pub size: u64,
pub etag: String,
pub modified_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListResponse {
pub items: Vec<FileEntry>,
pub next_cursor: Option<String>,
}
pub(crate) async fn list(
state: &AppState,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<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 = limit.unwrap_or(50).clamp(1, 200) as usize;
let cursor = cursor.unwrap_or_default();
let root_for_walk = serve_root.clone();
let bl = blocklist.clone();
let mut metas = tokio::task::spawn_blocking(move || collect_file_meta(&root_for_walk, &bl))
.await
.map_err(|e| AppError::Internal(format!("website file walk task panicked: {e}")))??;
metas.sort_by(|a, b| a.rel.cmp(&b.rel));
let start_idx = match metas.binary_search_by(|m| m.rel.as_str().cmp(cursor.as_str())) {
Ok(i) => i + 1,
Err(i) => i,
};
let mut window: Vec<FileMeta> = metas.into_iter().skip(start_idx).take(limit + 1).collect();
let next_cursor = if window.len() > limit {
Some(window[limit - 1].rel.clone())
} else {
None
};
window.truncate(limit);
let items = tokio::task::spawn_blocking(move || hash_window(window))
.await
.map_err(|e| AppError::Internal(format!("website file hash task panicked: {e}")))?;
Ok(ListResponse { items, next_cursor })
}
struct FileMeta {
rel: String,
abs: PathBuf,
size: u64,
modified_at: DateTime<Utc>,
}
fn collect_file_meta(root: &Path, blocklist: &[String]) -> Result<Vec<FileMeta>, 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 modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0))
.unwrap_or(DateTime::UNIX_EPOCH);
out.push(FileMeta {
rel: rel_str,
abs: path,
size: meta.len(),
modified_at,
});
}
}
Ok(out)
}
fn hash_window(window: Vec<FileMeta>) -> Vec<FileEntry> {
window
.into_iter()
.filter_map(|m| {
let bytes = std::fs::read(&m.abs).ok()?;
Some(FileEntry {
path: m.rel,
size: m.size,
etag: hex::encode(Sha256::digest(&bytes)),
modified_at: m.modified_at,
})
})
.collect()
}
pub(crate) enum WriteError {
Precondition(String),
Path(String),
App(AppError),
}
impl From<AppError> for WriteError {
fn from(e: AppError) -> Self {
Self::App(e)
}
}
pub(crate) async fn write_file(
state: &AppState,
actor: &str,
path: &str,
if_match: Option<&str>,
body: &[u8],
) -> Result<WebsiteWriteResponse, WriteError> {
let path = path.to_string();
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()
))
.into());
}
if deploy_mode == "managed" {
return Err(WriteError::Path(
"single-file writes are not supported in managed deploy mode; deploy a bundle".into(),
));
}
let req_path = format!("/{}", path.trim_start_matches('/'));
let target = canonical_within_root_for_create(&root_dir, &req_path, &blocklist)
.map_err(|e| WriteError::Path(write_path_error(&path, e).to_string()))?;
if let Some(parent) = target.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.map_err(|e| AppError::Internal(format!("mkdir -p {parent:?}: {e}")))?;
}
if let Some(if_match) = if_match {
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(WriteError::Precondition(format!(
"ifMatch {if_match} is not the current content hash of {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(
actor,
None,
AuditEvent::WebsiteFileWritten(WebsiteFileWrittenData {
path: path.clone(),
size_bytes,
sha256: digest_hex.clone(),
}),
)
.await;
}
Ok(WebsiteWriteResponse {
path,
etag,
size_bytes,
})
}
pub const FILES_DELETE_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::website::files::delete::v0_1::error_codes::NOT_FOUND.code;
pub(crate) async fn delete(
state: &AppState,
actor: &str,
path: String,
) -> Result<DeleteResponse, crate::error::TaskError> {
use crate::error::TaskError;
let resolved = resolve_or_400(state, &path).await.map_err(|e| match e {
e @ AppError::NotFound(_) => TaskError::declared(FILES_DELETE_ERR_NOT_FOUND, e),
e => TaskError::App(e),
})?;
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(
actor,
None,
AuditEvent::WebsiteFileDeleted(WebsiteFileDeletedData { path: path.clone() }),
)
.await;
}
Ok(DeleteResponse {
path,
deleted: true,
})
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[schema(as = WebsiteFileDeleteResponse)]
pub struct DeleteResponse {
pub path: String,
pub deleted: bool,
}
fn write_path_error(path: &str, err: PathError) -> AppError {
match err {
PathError::BlockedExtension(ext) => {
AppError::Forbidden(format!("extension {ext} is blocklisted"))
}
PathError::Hidden => AppError::NotFound(format!("no such file: {path}")),
_ => AppError::Validation(format!("path rejected by website path-safety: {path}")),
}
}
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}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn walk_gathers_metadata_and_hash_window_hashes_only_the_window() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
fs::write(root.join("index.html"), b"<h1>hi</h1>").unwrap();
fs::create_dir(root.join("assets")).unwrap();
fs::write(root.join("assets").join("app.js"), b"console.log(1)").unwrap();
fs::write(root.join("deploy.sh"), b"#!/bin/sh").unwrap(); fs::write(root.join(".hidden"), b"secret").unwrap();
let blocklist = vec![".sh".to_string()];
let mut metas = collect_file_meta(root, &blocklist).expect("walk");
metas.sort_by(|a, b| a.rel.cmp(&b.rel));
let rels: Vec<&str> = metas.iter().map(|m| m.rel.as_str()).collect();
assert_eq!(rels, vec!["assets/app.js", "index.html"]);
assert_eq!(
metas.iter().find(|m| m.rel == "index.html").unwrap().size,
11
);
let entries = hash_window(metas);
assert_eq!(entries.len(), 2);
let idx = entries.iter().find(|e| e.path == "index.html").unwrap();
assert_eq!(idx.etag, hex::encode(Sha256::digest(b"<h1>hi</h1>")));
assert_eq!(idx.size, 11);
}
}