1use 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; const READ_BUFFER_SIZE: usize = 1024 * 1024; #[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 #[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 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 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 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#[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}