Skip to main content

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::{header, HeaderMap, StatusCode},
10    response::{IntoResponse, Json, Response},
11};
12use serde_json::json;
13use tokio::io::{AsyncReadExt, AsyncSeekExt};
14
15use crate::backend::base::expand_home_dir_safe;
16use crate::backend::{docsite, schema};
17
18use super::AppState;
19
20#[derive(serde::Deserialize)]
21pub(super) struct FileQueryParams {
22    zoneid: Option<String>,
23    name: Option<String>,
24    #[serde(default)]
25    offset: i64,
26}
27
28#[derive(serde::Deserialize)]
29pub(super) struct LocalFileQueryParams {
30    path: Option<String>,
31}
32
33// Media pane (SPEC_MEDIA_PANE_2026_07_26.md): local video/image files run
34// larger than the 10MB text-editor cap `readeditorfile` uses — this
35// session's own generated clips ranged 6-28MB for a few seconds of
36// 1920x1080 footage. Sized for local video, not copied from the editor's
37// text-oriented number.
38const STREAM_LOCAL_FILE_MAX_BYTES: u64 = 500_000_000;
39
40pub(super) async fn handle_mux_file(
41    State(state): State<AppState>,
42    Query(params): Query<FileQueryParams>,
43) -> Response {
44    let zone_id = match &params.zoneid {
45        Some(z) if !z.is_empty() => z.as_str(),
46        _ => {
47            return (
48                StatusCode::BAD_REQUEST,
49                Json(json!({"error": "missing zoneid"})),
50            )
51                .into_response()
52        }
53    };
54    let name = match &params.name {
55        Some(n) if !n.is_empty() => n.as_str(),
56        _ => {
57            return (
58                StatusCode::BAD_REQUEST,
59                Json(json!({"error": "missing name"})),
60            )
61                .into_response()
62        }
63    };
64
65    // Get file metadata
66    let file_info = match state.filestore.stat(zone_id, name) {
67        Ok(Some(info)) => info,
68        Ok(None) => {
69            return (
70                StatusCode::NOT_FOUND,
71                Json(json!({"error": "file not found"})),
72            )
73                .into_response()
74        }
75        Err(e) => {
76            return (
77                StatusCode::INTERNAL_SERVER_ERROR,
78                Json(json!({"error": e.to_string()})),
79            )
80                .into_response()
81        }
82    };
83
84    // Read file data
85    let (_, data) = match state.filestore.read_at(zone_id, name, params.offset, 0) {
86        Ok(result) => result,
87        Err(e) => {
88            return (
89                StatusCode::INTERNAL_SERVER_ERROR,
90                Json(json!({"error": e.to_string()})),
91            )
92                .into_response()
93        }
94    };
95
96    // Build X-ZoneFileInfo header (base64-encoded JSON metadata)
97    let file_info_json = serde_json::to_string(&file_info).unwrap_or_default();
98    let file_info_b64 =
99        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &file_info_json);
100
101    Response::builder()
102        .status(StatusCode::OK)
103        .header("Content-Type", "application/octet-stream")
104        .header("X-ZoneFileInfo", file_info_b64)
105        .body(Body::from(data))
106        .unwrap_or_else(|_| {
107            (
108                StatusCode::INTERNAL_SERVER_ERROR,
109                "failed to build response",
110            )
111                .into_response()
112        })
113}
114
115pub(super) async fn handle_schema(
116    State(state): State<AppState>,
117    AxumPath(path): AxumPath<String>,
118) -> Response {
119    let app_path = if state.app_path.is_empty() {
120        return (
121            StatusCode::NOT_FOUND,
122            Json(json!({"error": "app path not configured"})),
123        )
124            .into_response();
125    } else {
126        PathBuf::from(&state.app_path)
127    };
128
129    let schema_dir = schema::get_schema_dir(&app_path);
130    let name = match schema::normalize_schema_request(&path) {
131        Some(n) => n,
132        None => {
133            return (
134                StatusCode::BAD_REQUEST,
135                Json(json!({"error": "invalid schema path"})),
136            )
137                .into_response()
138        }
139    };
140
141    match schema::resolve_schema_path(&schema_dir, &name) {
142        Some(file_path) => match std::fs::read(&file_path) {
143            Ok(data) => Response::builder()
144                .status(StatusCode::OK)
145                .header("Content-Type", schema::SCHEMA_CONTENT_TYPE)
146                .body(Body::from(data))
147                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
148            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
149        },
150        None => StatusCode::NOT_FOUND.into_response(),
151    }
152}
153
154pub(super) async fn handle_docsite(AxumPath(path): AxumPath<String>) -> Response {
155    match docsite::resolve_docsite_path(&path) {
156        Some(file_path) => {
157            let content_type = mime_from_path(&file_path);
158            match std::fs::read(&file_path) {
159                Ok(data) => Response::builder()
160                    .status(StatusCode::OK)
161                    .header("Content-Type", content_type)
162                    .body(Body::from(data))
163                    .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
164                Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
165            }
166        }
167        None => StatusCode::NOT_FOUND.into_response(),
168    }
169}
170
171fn mime_from_path(path: &std::path::Path) -> &'static str {
172    match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref() {
173        Some("html") => "text/html; charset=utf-8",
174        Some("css") => "text/css; charset=utf-8",
175        Some("js") => "application/javascript; charset=utf-8",
176        Some("json") => "application/json; charset=utf-8",
177        Some("png") => "image/png",
178        Some("jpg") | Some("jpeg") => "image/jpeg",
179        Some("svg") => "image/svg+xml",
180        Some("gif") => "image/gif",
181        Some("webp") => "image/webp",
182        Some("webm") => "video/webm",
183        Some("mp4") => "video/mp4",
184        Some("mov") => "video/quicktime",
185        Some("wav") => "audio/wav",
186        Some("woff2") => "font/woff2",
187        Some("woff") => "font/woff",
188        _ => "application/octet-stream",
189    }
190}
191
192/// Media pane (SPEC_MEDIA_PANE_2026_07_26.md): serve an arbitrary local file
193/// by absolute path, for `<img>`/`<video>` display. Deliberately matches
194/// `readeditorfile`'s existing posture (any absolute path the frontend
195/// sends, gated by OS-level permissions rather than an in-app allowlist —
196/// see the "root scoping, not a sandbox" note on the macOS `list_drives` in
197/// `editor_handlers.rs`) so
198/// this route isn't a stricter one-off next to an already-shipped read path
199/// with the same shape. Size-capped (see `STREAM_LOCAL_FILE_MAX_BYTES`)
200/// rather than truly unbounded.
201///
202/// Honours a single `Range` (`206 Partial Content`), so a `<video>` poster can
203/// read just the start of a file.
204pub(super) async fn handle_stream_local_file(
205    Query(params): Query<LocalFileQueryParams>,
206    headers: HeaderMap,
207) -> Response {
208    let raw_path = match &params.path {
209        Some(p) if !p.is_empty() => p.as_str(),
210        _ => {
211            return (
212                StatusCode::BAD_REQUEST,
213                Json(json!({"error": "missing path"})),
214            )
215                .into_response()
216        }
217    };
218
219    let expanded = expand_home_dir_safe(raw_path);
220    let path = expanded.as_path();
221
222    // Async metadata + streamed read, not std::fs::read — the earlier
223    // synchronous-read version blocked a shared Tokio worker thread and
224    // materialized the whole file in memory for every request, which is a
225    // real problem at this route's size ceiling (up to 500MB) and given the
226    // Media pane's live-update re-fetch pattern (repeated large reads as
227    // watched files change). Flagged in review (ReAgent P1, Codex P2).
228    let metadata = match tokio::fs::metadata(path).await {
229        Ok(m) => m,
230        Err(e) => {
231            return (
232                StatusCode::NOT_FOUND,
233                Json(json!({"error": format!("stream-local-file: {e}")})),
234            )
235                .into_response()
236        }
237    };
238    if !metadata.is_file() {
239        return (
240            StatusCode::BAD_REQUEST,
241            Json(json!({"error": "stream-local-file: not a file"})),
242        )
243            .into_response();
244    }
245    if metadata.len() > STREAM_LOCAL_FILE_MAX_BYTES {
246        return (
247            StatusCode::PAYLOAD_TOO_LARGE,
248            Json(json!({"error": "stream-local-file: file too large (>500MB)"})),
249        )
250            .into_response();
251    }
252
253    let total = metadata.len();
254    let range = headers
255        .get(header::RANGE)
256        .and_then(|v| v.to_str().ok())
257        .map_or(ByteRange::Full, |h| parse_byte_range(h, total));
258    if range == ByteRange::Unsatisfiable {
259        return Response::builder()
260            .status(StatusCode::RANGE_NOT_SATISFIABLE)
261            .header(header::CONTENT_RANGE, format!("bytes */{total}"))
262            .header(header::ACCEPT_RANGES, "bytes")
263            .body(Body::empty())
264            .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
265    }
266
267    let mut file = match tokio::fs::File::open(path).await {
268        Ok(f) => f,
269        Err(e) => {
270            return (
271                StatusCode::INTERNAL_SERVER_ERROR,
272                Json(json!({"error": format!("stream-local-file: {e}")})),
273            )
274                .into_response()
275        }
276    };
277    let builder = Response::builder()
278        .header(header::CONTENT_TYPE, mime_from_path(path))
279        .header(header::ACCEPT_RANGES, "bytes");
280    let response = match range {
281        // Only the requested bytes: seek, then cap the streamed read. Lets a
282        // <video> poster read a file's first bytes without fetching all of it
283        // (SPEC_AGENT_PANE_RICH_OUTPUT_2026_09_27.md §5).
284        ByteRange::Partial(start, end) => {
285            if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
286                return (
287                    StatusCode::INTERNAL_SERVER_ERROR,
288                    Json(json!({"error": format!("stream-local-file: {e}")})),
289                )
290                    .into_response();
291            }
292            let len = end - start + 1;
293            builder
294                .status(StatusCode::PARTIAL_CONTENT)
295                .header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))
296                .header(header::CONTENT_LENGTH, len.to_string())
297                .body(Body::from_stream(tokio_util::io::ReaderStream::new(file.take(len))))
298        }
299        _ => builder
300            .status(StatusCode::OK)
301            .header(header::CONTENT_LENGTH, total.to_string())
302            .body(Body::from_stream(tokio_util::io::ReaderStream::new(file))),
303    };
304    response.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
305}
306
307/// A request's `Range`, resolved against the file's size.
308#[derive(Debug, PartialEq, Eq)]
309pub(super) enum ByteRange {
310    /// No usable range: serve the whole file (`200`).
311    Full,
312    /// Inclusive byte offsets, both within the file (`206`).
313    Partial(u64, u64),
314    /// A valid range that starts past the end (`416`).
315    Unsatisfiable,
316}
317
318/// Parses a single `bytes=` range (`a-b`, `a-`, or the suffix form `-n`).
319/// Anything this doesn't understand — another unit, several ranges, `b < a`,
320/// junk — serves the whole file, which RFC 9110 §14.2 allows a server to do
321/// for any `Range` it chooses to ignore.
322pub(super) fn parse_byte_range(header: &str, total: u64) -> ByteRange {
323    let Some(spec) = header.trim().strip_prefix("bytes=") else {
324        return ByteRange::Full;
325    };
326    if spec.contains(',') {
327        return ByteRange::Full;
328    }
329    let Some((a, b)) = spec.split_once('-') else {
330        return ByteRange::Full;
331    };
332    let (a, b) = (a.trim(), b.trim());
333    if a.is_empty() {
334        let Ok(n) = b.parse::<u64>() else {
335            return ByteRange::Full;
336        };
337        if n == 0 || total == 0 {
338            return ByteRange::Unsatisfiable;
339        }
340        return ByteRange::Partial(total.saturating_sub(n), total - 1);
341    }
342    let Ok(start) = a.parse::<u64>() else {
343        return ByteRange::Full;
344    };
345    let end = if b.is_empty() {
346        None
347    } else {
348        match b.parse::<u64>() {
349            Ok(e) if e >= start => Some(e),
350            _ => return ByteRange::Full,
351        }
352    };
353    if start >= total {
354        return ByteRange::Unsatisfiable;
355    }
356    ByteRange::Partial(start, end.map_or(total - 1, |e| e.min(total - 1)))
357}
358
359#[cfg(test)]
360mod stream_local_file_range_tests {
361    use super::*;
362    use axum::http::{header, HeaderMap, HeaderValue};
363
364    // ── parse_byte_range ────────────────────────────────────────────────
365
366    #[test]
367    fn a_closed_range_is_partial() {
368        assert_eq!(parse_byte_range("bytes=0-99", 1000), ByteRange::Partial(0, 99));
369        assert_eq!(parse_byte_range("bytes=100-199", 1000), ByteRange::Partial(100, 199));
370    }
371
372    #[test]
373    fn an_end_past_the_file_is_clamped() {
374        assert_eq!(parse_byte_range("bytes=900-5000", 1000), ByteRange::Partial(900, 999));
375    }
376
377    #[test]
378    fn an_open_range_runs_to_the_end() {
379        assert_eq!(parse_byte_range("bytes=500-", 1000), ByteRange::Partial(500, 999));
380    }
381
382    #[test]
383    fn a_suffix_range_is_the_last_n_bytes() {
384        assert_eq!(parse_byte_range("bytes=-100", 1000), ByteRange::Partial(900, 999));
385        assert_eq!(parse_byte_range("bytes=-5000", 1000), ByteRange::Partial(0, 999));
386    }
387
388    #[test]
389    fn a_start_past_the_end_is_unsatisfiable() {
390        assert_eq!(parse_byte_range("bytes=1000-", 1000), ByteRange::Unsatisfiable);
391        assert_eq!(parse_byte_range("bytes=0-0", 0), ByteRange::Unsatisfiable);
392        assert_eq!(parse_byte_range("bytes=-0", 1000), ByteRange::Unsatisfiable);
393    }
394
395    #[test]
396    fn anything_else_serves_the_whole_file() {
397        for h in ["bytes=5-2", "bytes=abc-", "items=0-1", "bytes=0-1,5-9", "bytes=-", ""] {
398            assert_eq!(parse_byte_range(h, 1000), ByteRange::Full, "{h:?}");
399        }
400    }
401
402    // ── handler ─────────────────────────────────────────────────────────
403
404    fn file_with(bytes: &[u8]) -> tempfile::NamedTempFile {
405        let f = tempfile::Builder::new().suffix(".webm").tempfile().unwrap();
406        std::fs::write(f.path(), bytes).unwrap();
407        f
408    }
409
410    async fn get(path: &std::path::Path, range: Option<&str>) -> (StatusCode, HeaderMap, Vec<u8>) {
411        let mut headers = HeaderMap::new();
412        if let Some(r) = range {
413            headers.insert(header::RANGE, HeaderValue::from_str(r).unwrap());
414        }
415        let params = LocalFileQueryParams { path: Some(path.to_string_lossy().into_owned()) };
416        let resp = handle_stream_local_file(Query(params), headers).await;
417        let status = resp.status();
418        let hdrs = resp.headers().clone();
419        let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec();
420        (status, hdrs, body)
421    }
422
423    #[tokio::test]
424    async fn no_range_serves_the_whole_file_and_advertises_ranges() {
425        let f = file_with(b"0123456789");
426        let (status, h, body) = get(f.path(), None).await;
427        assert_eq!(status, StatusCode::OK);
428        assert_eq!(body, b"0123456789");
429        assert_eq!(h[header::ACCEPT_RANGES], "bytes");
430        assert_eq!(h[header::CONTENT_LENGTH], "10");
431    }
432
433    #[tokio::test]
434    async fn a_range_returns_206_with_just_those_bytes() {
435        let f = file_with(b"0123456789");
436        let (status, h, body) = get(f.path(), Some("bytes=2-5")).await;
437        assert_eq!(status, StatusCode::PARTIAL_CONTENT);
438        assert_eq!(body, b"2345");
439        assert_eq!(h[header::CONTENT_RANGE], "bytes 2-5/10");
440        assert_eq!(h[header::CONTENT_LENGTH], "4");
441        assert_eq!(h[header::CONTENT_TYPE], "video/webm");
442    }
443
444    #[tokio::test]
445    async fn an_unsatisfiable_range_is_416_with_the_size() {
446        let f = file_with(b"0123456789");
447        let (status, h, _) = get(f.path(), Some("bytes=50-")).await;
448        assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
449        assert_eq!(h[header::CONTENT_RANGE], "bytes */10");
450    }
451}