juicehost/
handlers.rs

1//! All the request handlers for juicehost routes.
2//!
3//! File serving with ETags, multipart and streaming storage, rename, delete,
4//! health checks, and storage info.
5
6use std::sync::Arc;
7
8use axum::{
9    body::Body,
10    extract::{Multipart, Path, State},
11    http::{header, HeaderMap, StatusCode},
12    response::{Json, Response},
13};
14use serde::Serialize;
15use tokio::io::AsyncWriteExt;
16use tokio_util::io::ReaderStream;
17
18use crate::state::{AppState, CacheEntry};
19
20const MAX_STORE_FILE_SIZE: u64 = 10 * 1024 * 1024 * 1024; // 10 GB
21const READ_BUFFER_SIZE: usize = 1024 * 1024; // 1 MB read buffer
22
23#[derive(Serialize)]
24pub struct StorageInfo {
25    pub total_bytes: u64,
26    pub used_bytes: u64,
27    pub free_bytes: u64,
28    pub min_free_bytes: u64,
29    pub out_of_space: bool,
30}
31
32fn check_disk_space(state: &Arc<AppState>) -> Result<(), (StatusCode, &'static str)> {
33    let free = fs2::available_space(&state.files_dir)
34        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to check disk space"))?;
35    if free < state.min_free_space_bytes {
36        return Err((
37            StatusCode::INSUFFICIENT_STORAGE,
38            "This instance is out of storage! Try again later.",
39        ));
40    }
41    Ok(())
42}
43
44fn storage_info(state: &Arc<AppState>) -> StorageInfo {
45    let total = fs2::total_space(&state.files_dir).unwrap_or(0);
46    let free = fs2::available_space(&state.files_dir).unwrap_or(0);
47    let used = total.saturating_sub(free);
48    StorageInfo {
49        total_bytes: total,
50        used_bytes: used,
51        free_bytes: free,
52        min_free_bytes: state.min_free_space_bytes,
53        out_of_space: free < state.min_free_space_bytes,
54    }
55}
56
57fn etag_from_metadata(meta: &std::fs::Metadata) -> String {
58    // Use inode, mtime, and size for a strong ETag
59    #[cfg(unix)]
60    {
61        use std::os::unix::fs::MetadataExt;
62        format!(
63            "\"{:x}-{:x}-{:x}\"",
64            meta.ino(),
65            meta.mtime() as u64,
66            meta.len()
67        )
68    }
69    #[cfg(not(unix))]
70    {
71        format!(
72            "\"{:x}-{:x}\"",
73            meta.modified().ok().map(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0)).unwrap_or(0),
74            meta.len()
75        )
76    }
77}
78
79fn guess_mime(path: &std::path::Path) -> String {
80    let mime = mime_guess::from_path(path).first_or_octet_stream();
81    if mime.type_() == mime_guess::mime::TEXT {
82        format!("{}; charset=utf-8", mime)
83    } else {
84        mime.to_string()
85    }
86}
87
88#[tracing::instrument(skip_all)]
89pub async fn serve_file(
90    State(state): State<Arc<AppState>>,
91    headers: HeaderMap,
92    Path(id): Path<String>,
93) -> Result<Response<Body>, (StatusCode, &'static str)> {
94    if id.chars().any(|c| !c.is_alphanumeric() && c != '-' && c != '_') {
95        return Err((StatusCode::BAD_REQUEST, "Invalid ID format"));
96    }
97
98    let lookup_start = std::time::Instant::now();
99    let entry = state.files_cache.get(&id).map(|e| e.value().clone());
100
101    let entry = match entry {
102        Some(e) if e.path.exists() => e,
103        _ => {
104            let path = find_file_by_id(&state.files_dir, &id).await;
105            match path {
106                Some(p) => {
107                    let entry = CacheEntry { path: p };
108                    state.files_cache.insert(id.clone(), entry.clone());
109                    entry
110                }
111                None => return Err((StatusCode::NOT_FOUND, "File not found")),
112            }
113        }
114    };
115    tracing::debug!("file lookup took {:?}", lookup_start.elapsed());
116
117    let meta_start = std::time::Instant::now();
118    let meta = match tokio::fs::metadata(&entry.path).await {
119        Ok(m) => m,
120        Err(e) => {
121            tracing::error!("serve_file: metadata failed for {:?}: {}", entry.path, e);
122            return Err((StatusCode::NOT_FOUND, "File not found"));
123        }
124    };
125    tracing::debug!("metadata took {:?}", meta_start.elapsed());
126
127    let etag = etag_from_metadata(&meta);
128
129    // Conditional GET: return 304 if ETag matches
130    if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
131        if let Ok(val) = if_none_match.to_str() {
132            if val.trim().trim_matches('"') == etag.trim_matches('"') {
133                return Ok(Response::builder()
134                    .status(StatusCode::NOT_MODIFIED)
135                    .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
136                    .header(header::ETAG, &etag)
137                    .body(Body::empty())
138                    .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response"))?);
139            }
140        }
141    }
142
143    let read_start = std::time::Instant::now();
144    let file = match tokio::fs::File::open(&entry.path).await {
145        Ok(f) => f,
146        Err(e) => {
147            tracing::error!("serve_file: failed to open {:?}: {}", entry.path, e);
148            return Err((StatusCode::NOT_FOUND, "File not found"));
149        }
150    };
151
152    let len = meta.len();
153    let mime_str = guess_mime(&entry.path);
154
155    let stream = ReaderStream::with_capacity(file, READ_BUFFER_SIZE);
156    let body = Body::from_stream(stream);
157    tracing::debug!("file open + stream setup took {:?} ({} bytes)", read_start.elapsed(), len);
158
159    let response = Response::builder()
160        .status(StatusCode::OK)
161        .header(header::CONTENT_TYPE, &mime_str)
162        .header(header::CONTENT_DISPOSITION, "inline")
163        .header(header::CONTENT_LENGTH, len)
164        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
165        .header(header::ETAG, &etag);
166    response.body(body)
167        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response"))
168}
169
170fn json_err(code: StatusCode, msg: &'static str) -> (StatusCode, Json<serde_json::Value>) {
171    (code, Json(serde_json::json!({"error": "STORAGE_ERROR", "message": msg})))
172}
173
174#[tracing::instrument(skip_all)]
175pub async fn store_file(
176    State(state): State<Arc<AppState>>,
177    mut multipart: Multipart,
178) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
179    check_disk_space(&state).map_err(|(code, msg)| {
180        (code, Json(serde_json::json!({"error": "INSUFFICIENT_STORAGE", "message": msg})))
181    })?;
182
183    let mut file_id = String::new();
184    let mut filename = String::new();
185    let mut file_path: Option<std::path::PathBuf> = None;
186
187    while let Ok(Some(mut field)) = multipart.next_field().await {
188        let name = field.name().unwrap_or("").to_string();
189        match name.as_str() {
190            "id" => {
191                file_id = field.text().await.unwrap_or_default();
192            }
193            "filename" => {
194                filename = field.text().await.unwrap_or_default();
195            }
196            "file" => {
197                if file_id.is_empty() {
198                    return Err(json_err(StatusCode::BAD_REQUEST, "Missing id before file field"));
199                }
200
201                if file_id.chars().any(|c| !c.is_alphanumeric() && c != '-' && c != '_') || file_id.is_empty() {
202                    return Err(json_err(StatusCode::BAD_REQUEST, "Invalid file ID format"));
203                }
204
205                let ext = std::path::Path::new(&filename)
206                    .extension()
207                    .and_then(|e| e.to_str())
208                    .unwrap_or("bin");
209                let storage_name = format!("{}.{}", file_id, ext);
210                let sp = state.files_dir.join(&storage_name);
211
212                if sp.exists() {
213                    return Err(json_err(StatusCode::CONFLICT, "File already exists"));
214                }
215
216                let write_start = std::time::Instant::now();
217                let mut file = tokio::fs::File::create(&sp).await
218                    .map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create file"))?;
219
220                let mut total: u64 = 0;
221                while let Ok(Some(chunk)) = field.chunk().await {
222                    total += chunk.len() as u64;
223                    if total > MAX_STORE_FILE_SIZE {
224                        drop(file);
225                        let _ = tokio::fs::remove_file(&sp).await;
226                        return Err(json_err(StatusCode::PAYLOAD_TOO_LARGE, "File too large"));
227                    }
228                    file.write_all(&chunk).await
229                        .map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to write file"))?;
230                }
231
232                tracing::debug!("file write took {:?} ({} bytes)", write_start.elapsed(), total);
233                file_path = Some(sp);
234            }
235            _ => {}
236        }
237    }
238
239    let file_path = match file_path {
240        Some(p) => p,
241        None => return Err(json_err(StatusCode::BAD_REQUEST, "Missing file field")),
242    };
243
244    if file_id.is_empty() {
245        let _ = tokio::fs::remove_file(&file_path).await;
246        return Err(json_err(StatusCode::BAD_REQUEST, "Missing id"));
247    }
248
249    state.files_cache.insert(file_id.clone(), CacheEntry { path: file_path });
250
251    tracing::info!("stored file: {} ({})", file_id, filename);
252
253    Ok(Json(serde_json::json!({"status": "ok", "id": file_id})))
254}
255
256pub async fn rename_file(
257    State(state): State<Arc<AppState>>,
258    Path(id): Path<String>,
259    Json(payload): Json<serde_json::Value>,
260) -> Result<Json<serde_json::Value>, (StatusCode, &'static str)> {
261    let new_id = payload
262        .get("new_id")
263        .and_then(|v| v.as_str())
264        .ok_or((StatusCode::BAD_REQUEST, "Missing new_id"))?;
265
266    if new_id.is_empty() || new_id.chars().any(|c| !c.is_alphanumeric() && c != '-' && c != '_') {
267        return Err((StatusCode::BAD_REQUEST, "Invalid new_id format"));
268    }
269
270    // Find the existing file by id (scanning files_dir for {id}.*)
271    let old_path = find_file_by_id(&state.files_dir, &id).await
272        .ok_or((StatusCode::NOT_FOUND, "File not found"))?;
273
274    let ext = old_path
275        .extension()
276        .and_then(|e| e.to_str())
277        .unwrap_or("bin");
278    let new_storage_name = format!("{}.{}", new_id, ext);
279    let new_path = state.files_dir.join(&new_storage_name);
280
281    if new_path.exists() {
282        return Err((StatusCode::CONFLICT, "Target file already exists"));
283    }
284
285    tokio::fs::rename(&old_path, &new_path)
286        .await
287        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to rename file"))?;
288
289    // Update cache
290    state.files_cache.remove(&*id);
291    state.files_cache.insert(new_id.to_string(), CacheEntry { path: new_path });
292
293    tracing::info!("renamed file: {} -> {}", id, new_id);
294
295    Ok(Json(serde_json::json!({"status": "ok", "old_id": id, "new_id": new_id})))
296}
297
298pub async fn delete_file(
299    State(state): State<Arc<AppState>>,
300    Path(id): Path<String>,
301) -> StatusCode {
302    let entry = state.files_cache.get(&id).map(|e| e.value().clone());
303
304    match entry {
305        Some(e) => {
306            if tokio::fs::remove_file(&e.path).await.is_ok() {
307                state.files_cache.remove(&id);
308                tracing::info!("deleted file: {}", id);
309                StatusCode::NO_CONTENT
310            } else {
311                tracing::error!("delete_file: failed to remove {:?}", e.path);
312                StatusCode::INTERNAL_SERVER_ERROR
313            }
314        }
315        None => {
316            let path = find_file_by_id(&state.files_dir, &id).await;
317            match path {
318                Some(p) => {
319                    if tokio::fs::remove_file(&p).await.is_ok() {
320                        tracing::info!("deleted file: {}", id);
321                        StatusCode::NO_CONTENT
322                    } else {
323                        StatusCode::INTERNAL_SERVER_ERROR
324                    }
325                }
326                None => StatusCode::NOT_FOUND,
327            }
328        }
329    }
330}
331
332async fn find_file_by_id(files_dir: &std::path::Path, id: &str) -> Option<std::path::PathBuf> {
333    let mut entries = tokio::fs::read_dir(files_dir).await.ok()?;
334    while let Ok(Some(entry)) = entries.next_entry().await {
335        let name = entry.file_name().to_string_lossy().to_string();
336        if name.starts_with(&format!("{}.", id)) {
337            return Some(entry.path());
338        }
339    }
340    None
341}
342
343/// Accepts a raw body stream and writes chunks to disk as they arrive.
344/// File ID and name come from the URL path, mime type from a header.
345#[tracing::instrument(skip_all)]
346pub async fn store_file_streaming(
347    State(state): State<Arc<AppState>>,
348    Path((id, filename)): Path<(String, String)>,
349    _headers: HeaderMap,
350    body: axum::body::Body,
351) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
352    check_disk_space(&state).map_err(|(code, msg)| {
353        (code, Json(serde_json::json!({"error": "INSUFFICIENT_STORAGE", "message": msg})))
354    })?;
355
356    if id.chars().any(|c| !c.is_alphanumeric() && c != '-' && c != '_') || id.is_empty() {
357        return Err(json_err(StatusCode::BAD_REQUEST, "Invalid file ID format"));
358    }
359
360    let ext = std::path::Path::new(&filename)
361        .extension()
362        .and_then(|e| e.to_str())
363        .unwrap_or("bin");
364    let storage_name = format!("{}.{}", id, ext);
365    let sp = state.files_dir.join(&storage_name);
366
367    if sp.exists() {
368        return Err(json_err(StatusCode::CONFLICT, "File already exists"));
369    }
370
371    let handler_start = std::time::Instant::now();
372    let mut file = tokio::fs::File::create(&sp).await
373        .map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create file"))?;
374    let file_create_time = handler_start.elapsed();
375
376    use tokio::io::AsyncWriteExt;
377    use futures::StreamExt;
378    let mut stream = body.into_data_stream();
379    let mut total: u64 = 0;
380    let mut first_byte = true;
381    let mut chunk_count: u64 = 0;
382    while let Some(chunk) = stream.next().await {
383        let chunk = chunk.map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read body"))?;
384        if first_byte {
385            tracing::debug!(
386                "stream_store first_byte: id={} file_create={:?} first_byte_at={:?}",
387                id, file_create_time, handler_start.elapsed(),
388            );
389            first_byte = false;
390        }
391        chunk_count += 1;
392        total += chunk.len() as u64;
393        if total > MAX_STORE_FILE_SIZE {
394            drop(file);
395            let _ = tokio::fs::remove_file(&sp).await;
396            return Err(json_err(StatusCode::PAYLOAD_TOO_LARGE, "File too large"));
397        }
398        file.write_all(&chunk).await
399            .map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to write file"))?;
400    }
401    file.flush().await.map_err(|_| json_err(StatusCode::INTERNAL_SERVER_ERROR, "Failed to flush file"))?;
402    let total_time = handler_start.elapsed();
403    let bytes_per_sec = if total_time.as_secs_f64() > 0.0 {
404        total as f64 / total_time.as_secs_f64()
405    } else {
406        0.0
407    };
408    tracing::debug!(
409        "stream_store done: id={} chunks={} bytes={} total={:?} {:.2} MB/s",
410        id, chunk_count, total, total_time, bytes_per_sec / (1024.0 * 1024.0),
411    );
412
413    state.files_cache.insert(id.clone(), CacheEntry { path: sp });
414
415    tracing::info!("stored file (streaming): {} ({})", id, filename);
416    Ok(Json(serde_json::json!({"status": "ok", "id": id})))
417}
418
419pub async fn health(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
420    let si = storage_info(&state);
421    Json(serde_json::json!({
422        "status": "ok",
423        "juicehost": "ok",
424        "juiceback": "unknown",
425        "storage": {
426            "total_bytes": si.total_bytes,
427            "used_bytes": si.used_bytes,
428            "free_bytes": si.free_bytes,
429            "min_free_bytes": si.min_free_bytes,
430            "out_of_space": si.out_of_space,
431        },
432    }))
433}
434
435pub async fn storage_handler(State(state): State<Arc<AppState>>) -> Json<StorageInfo> {
436    Json(storage_info(&state))
437}