1use 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
33const 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 ¶ms.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 ¶ms.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 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 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 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
192pub(super) async fn handle_stream_local_file(
205 Query(params): Query<LocalFileQueryParams>,
206 headers: HeaderMap,
207) -> Response {
208 let raw_path = match ¶ms.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 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 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#[derive(Debug, PartialEq, Eq)]
309pub(super) enum ByteRange {
310 Full,
312 Partial(u64, u64),
314 Unsatisfiable,
316}
317
318pub(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 #[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 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}