vtc-service 0.7.0

Service for Verifiable Trust Communities
//! `/v1/website/files` handlers (Phase 5 M5.5.1 + M5.5.2).
//!
//! - `GET /v1/website/files` — admin paginated listing.
//! - `GET /v1/website/files/{*path}` — admin file read.
//! - `PUT /v1/website/files/{*path}` — admin write with optional
//!   `If-Match` optimistic concurrency.
//! - `DELETE /v1/website/files/{*path}` — admin delete.

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>,
}

/// `GET /v1/website/files`
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));

    // Cursor is an opaque path string — next page starts at the
    // first entry whose path > cursor.
    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();
            // Hidden files / dirs (leading `.`) are excluded from
            // listings to match the public handler.
            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;
            }
            // Skip files whose extension is in the blocklist — the
            // public handler would refuse to serve them, so the
            // listing shouldn't tease them.
            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)
}

/// `GET /v1/website/files/{*path}`
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)
}

/// `PUT /v1/website/files/{*path}`
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()
        )));
    }

    // Live mode writes directly into root_dir. Managed mode
    // refuses single-file writes because every change has to land
    // in a new generation; the operator must use POST /deploy.
    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('/'));
    // Path safety: target file might not exist yet, so we use a
    // shadow check against the parent + file name rather than
    // calling `canonical_within_root` (which requires the file to
    // exist). The parent must exist within root, and the file
    // name must pass the same rules.
    let _ = blocklist; // applied by listing; PUT validates via canonicalisation below
    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(),
        ));
    }

    // Optional If-Match optimistic concurrency.
    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}"
            )));
        }
    }

    // Atomic single-file write: write to a temp file in the same
    // directory, then rename.
    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,
        }),
    ))
}

/// `DELETE /v1/website/files/{*path}`
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}"
        ))),
    }
}

// Suppress unused-import warning for IntoResponse — used through
// `Json::into_response` implicitly via the `?` mapping.
#[allow(dead_code)]
fn _unused(_x: impl IntoResponse) {}