agentmux_srv\server/
files.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::path::PathBuf;
5
6use axum::{
7    body::Body,
8    extract::{Path as AxumPath, Query, State},
9    http::StatusCode,
10    response::{IntoResponse, Json, Response},
11};
12use serde_json::json;
13
14use crate::backend::{docsite, schema};
15
16use super::AppState;
17
18#[derive(serde::Deserialize)]
19pub(super) struct FileQueryParams {
20    zoneid: Option<String>,
21    name: Option<String>,
22    #[serde(default)]
23    offset: i64,
24}
25
26pub(super) async fn handle_wave_file(
27    State(state): State<AppState>,
28    Query(params): Query<FileQueryParams>,
29) -> Response {
30    let zone_id = match &params.zoneid {
31        Some(z) if !z.is_empty() => z.as_str(),
32        _ => {
33            return (
34                StatusCode::BAD_REQUEST,
35                Json(json!({"error": "missing zoneid"})),
36            )
37                .into_response()
38        }
39    };
40    let name = match &params.name {
41        Some(n) if !n.is_empty() => n.as_str(),
42        _ => {
43            return (
44                StatusCode::BAD_REQUEST,
45                Json(json!({"error": "missing name"})),
46            )
47                .into_response()
48        }
49    };
50
51    // Get file metadata
52    let file_info = match state.filestore.stat(zone_id, name) {
53        Ok(Some(info)) => info,
54        Ok(None) => {
55            return (
56                StatusCode::NOT_FOUND,
57                Json(json!({"error": "file not found"})),
58            )
59                .into_response()
60        }
61        Err(e) => {
62            return (
63                StatusCode::INTERNAL_SERVER_ERROR,
64                Json(json!({"error": e.to_string()})),
65            )
66                .into_response()
67        }
68    };
69
70    // Read file data
71    let (_, data) = match state.filestore.read_at(zone_id, name, params.offset, 0) {
72        Ok(result) => result,
73        Err(e) => {
74            return (
75                StatusCode::INTERNAL_SERVER_ERROR,
76                Json(json!({"error": e.to_string()})),
77            )
78                .into_response()
79        }
80    };
81
82    // Build X-ZoneFileInfo header (base64-encoded JSON metadata)
83    let file_info_json = serde_json::to_string(&file_info).unwrap_or_default();
84    let file_info_b64 =
85        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &file_info_json);
86
87    Response::builder()
88        .status(StatusCode::OK)
89        .header("Content-Type", "application/octet-stream")
90        .header("X-ZoneFileInfo", file_info_b64)
91        .body(Body::from(data))
92        .unwrap_or_else(|_| {
93            (
94                StatusCode::INTERNAL_SERVER_ERROR,
95                "failed to build response",
96            )
97                .into_response()
98        })
99}
100
101pub(super) async fn handle_schema(
102    State(state): State<AppState>,
103    AxumPath(path): AxumPath<String>,
104) -> Response {
105    let app_path = if state.app_path.is_empty() {
106        return (
107            StatusCode::NOT_FOUND,
108            Json(json!({"error": "app path not configured"})),
109        )
110            .into_response();
111    } else {
112        PathBuf::from(&state.app_path)
113    };
114
115    let schema_dir = schema::get_schema_dir(&app_path);
116    let name = match schema::normalize_schema_request(&path) {
117        Some(n) => n,
118        None => {
119            return (
120                StatusCode::BAD_REQUEST,
121                Json(json!({"error": "invalid schema path"})),
122            )
123                .into_response()
124        }
125    };
126
127    match schema::resolve_schema_path(&schema_dir, &name) {
128        Some(file_path) => match std::fs::read(&file_path) {
129            Ok(data) => Response::builder()
130                .status(StatusCode::OK)
131                .header("Content-Type", schema::SCHEMA_CONTENT_TYPE)
132                .body(Body::from(data))
133                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
134            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
135        },
136        None => StatusCode::NOT_FOUND.into_response(),
137    }
138}
139
140pub(super) async fn handle_docsite(AxumPath(path): AxumPath<String>) -> Response {
141    match docsite::resolve_docsite_path(&path) {
142        Some(file_path) => {
143            let content_type = mime_from_path(&file_path);
144            match std::fs::read(&file_path) {
145                Ok(data) => Response::builder()
146                    .status(StatusCode::OK)
147                    .header("Content-Type", content_type)
148                    .body(Body::from(data))
149                    .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
150                Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
151            }
152        }
153        None => StatusCode::NOT_FOUND.into_response(),
154    }
155}
156
157fn mime_from_path(path: &std::path::Path) -> &'static str {
158    match path.extension().and_then(|e| e.to_str()) {
159        Some("html") => "text/html; charset=utf-8",
160        Some("css") => "text/css; charset=utf-8",
161        Some("js") => "application/javascript; charset=utf-8",
162        Some("json") => "application/json; charset=utf-8",
163        Some("png") => "image/png",
164        Some("jpg") | Some("jpeg") => "image/jpeg",
165        Some("svg") => "image/svg+xml",
166        Some("woff2") => "font/woff2",
167        Some("woff") => "font/woff",
168        _ => "application/octet-stream",
169    }
170}