acton_htmx/middleware/
file_serving.rs

1//! File serving middleware with range requests, caching, and access control
2//!
3//! This module provides middleware for serving uploaded files with:
4//! - Range request support for streaming and resumable downloads
5//! - Proper cache headers (ETag, Last-Modified, Cache-Control)
6//! - CDN integration hints
7//! - Access control for private files
8//!
9//! # Examples
10//!
11//! ## Basic file serving
12//!
13//! ```rust,no_run
14//! use acton_htmx::middleware::FileServingMiddleware;
15//! use acton_htmx::storage::{FileStorage, LocalFileStorage};
16//! use std::path::PathBuf;
17//! use std::sync::Arc;
18//! use axum::{Router, routing::get};
19//!
20//! # async fn example() -> anyhow::Result<()> {
21//! let storage = Arc::new(LocalFileStorage::new(PathBuf::from("/var/uploads"))?);
22//! let middleware = FileServingMiddleware::new(storage);
23//!
24//! let app = Router::new()
25//!     .route("/files/:id", get(|| async { "file handler" }))
26//!     .layer(middleware);
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! ## With access control
32//!
33//! ```rust,no_run
34//! use acton_htmx::middleware::{FileServingMiddleware, FileAccessControl};
35//! use acton_htmx::storage::{FileStorage, LocalFileStorage};
36//! use std::path::PathBuf;
37//! use std::sync::Arc;
38//!
39//! # async fn example() -> anyhow::Result<()> {
40//! let storage = Arc::new(LocalFileStorage::new(PathBuf::from("/var/uploads"))?);
41//!
42//! // Custom access control
43//! let access_control: FileAccessControl = Arc::new(|user_id, file_id| {
44//!     Box::pin(async move {
45//!         // Check if user owns the file or is admin
46//!         Ok(true)
47//!     })
48//! });
49//!
50//! let middleware = FileServingMiddleware::new(storage)
51//!     .with_access_control(access_control);
52//! # Ok(())
53//! # }
54//! ```
55
56use crate::storage::{FileStorage, StorageError, StorageResult};
57use axum::{
58    body::Body,
59    extract::{Path, State},
60    http::{
61        header::{
62            ACCEPT_RANGES, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG,
63            IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
64        },
65        HeaderMap, HeaderValue, StatusCode,
66    },
67    response::{IntoResponse, Response},
68};
69use std::{
70    future::Future,
71    pin::Pin,
72    sync::Arc,
73    time::SystemTime,
74};
75
76/// Access control function type for file serving
77///
78/// Takes user ID (if authenticated) and file ID, returns whether access is allowed.
79pub type FileAccessControl = Arc<
80    dyn Fn(Option<String>, String) -> Pin<Box<dyn Future<Output = StorageResult<bool>> + Send>>
81        + Send
82        + Sync,
83>;
84
85/// Middleware for serving files with range requests, caching, and access control
86#[derive(Clone)]
87pub struct FileServingMiddleware<S: FileStorage> {
88    #[allow(dead_code)] // Used in future layer implementation
89    storage: Arc<S>,
90    #[allow(dead_code)] // Used in future layer implementation
91    access_control: Option<FileAccessControl>,
92    #[allow(dead_code)] // Used in future layer implementation
93    cache_max_age: u32,
94    #[allow(dead_code)] // Used in future layer implementation
95    enable_cdn_headers: bool,
96}
97
98impl<S: FileStorage> FileServingMiddleware<S> {
99    /// Create a new file serving middleware
100    #[must_use]
101    pub fn new(storage: Arc<S>) -> Self {
102        Self {
103            storage,
104            access_control: None,
105            cache_max_age: 86400, // 1 day default
106            enable_cdn_headers: false,
107        }
108    }
109
110    /// Set custom access control function
111    #[must_use]
112    pub fn with_access_control(mut self, access_control: FileAccessControl) -> Self {
113        self.access_control = Some(access_control);
114        self
115    }
116
117    /// Set cache max-age in seconds (default: 86400 = 1 day)
118    #[must_use]
119    pub const fn with_cache_max_age(mut self, seconds: u32) -> Self {
120        self.cache_max_age = seconds;
121        self
122    }
123
124    /// Enable CDN-friendly headers
125    #[must_use]
126    pub const fn with_cdn_headers(mut self) -> Self {
127        self.enable_cdn_headers = true;
128        self
129    }
130}
131
132/// Handler for serving a single file with range request support
133///
134/// This should be used as an Axum route handler for file serving endpoints.
135///
136/// # Errors
137///
138/// Returns [`FileServingError`] if:
139/// - File metadata cannot be retrieved (file not found, storage error)
140/// - File data cannot be retrieved from storage
141/// - Range request parsing fails (invalid Range header)
142/// - Content type detection fails
143///
144/// # Examples
145///
146/// ```rust,no_run
147/// use axum::{Router, routing::get};
148/// use acton_htmx::middleware::serve_file;
149/// use acton_htmx::storage::LocalFileStorage;
150/// use std::path::PathBuf;
151/// use std::sync::Arc;
152///
153/// # async fn example() -> anyhow::Result<()> {
154/// let storage = Arc::new(LocalFileStorage::new(PathBuf::from("/var/uploads"))?);
155///
156/// let app = Router::new()
157///     .route("/files/:id", get(serve_file::<LocalFileStorage>))
158///     .with_state(storage);
159/// # Ok(())
160/// # }
161/// ```
162pub async fn serve_file<S: FileStorage>(
163    State(storage): State<Arc<S>>,
164    Path(file_id): Path<String>,
165    headers: HeaderMap,
166) -> Result<Response, FileServingError> {
167    // Retrieve file metadata for content type and other info
168    let metadata = storage
169        .get_metadata(&file_id)
170        .await
171        .map_err(FileServingError::Storage)?;
172
173    // Retrieve file data
174    let data = storage
175        .retrieve(&file_id)
176        .await
177        .map_err(FileServingError::Storage)?;
178
179    // Generate ETag from file ID and size
180    let etag = format!(r#""{}-{}""#, file_id, data.len());
181
182    // Use content type from metadata, with mime_guess fallback
183    let content_type = if !metadata.content_type.is_empty()
184        && metadata.content_type != "application/octet-stream"
185    {
186        metadata.content_type
187    } else {
188        // Fallback to MIME type detection from filename
189        mime_guess::from_path(&metadata.filename)
190            .first_or_octet_stream()
191            .to_string()
192    };
193
194    // Check If-None-Match (ETag validation)
195    if let Some(if_none_match) = headers.get(IF_NONE_MATCH) {
196        if if_none_match.to_str().is_ok_and(|v| v == etag) {
197            return Ok((StatusCode::NOT_MODIFIED, ()).into_response());
198        }
199    }
200
201    // Check for range request
202    if let Some(range_header) = headers.get(RANGE) {
203        return serve_range_request(&data, range_header, &etag, &content_type, &headers);
204    }
205
206    // Serve complete file
207    Ok(build_file_response(data, &etag, &content_type, None))
208}
209
210/// Serve a range request (partial content)
211fn serve_range_request(
212    data: &[u8],
213    range_header: &HeaderValue,
214    etag: &str,
215    content_type: &str,
216    headers: &HeaderMap,
217) -> Result<Response, FileServingError> {
218    let file_size = data.len();
219
220    // Check If-Range header (validate ETag before serving range)
221    if let Some(if_range) = headers.get(IF_RANGE) {
222        if if_range.to_str().map_or(true, |v| v != etag) {
223            // ETag doesn't match, serve full file instead
224            return Ok(build_file_response(data.to_vec(), etag, content_type, None));
225        }
226    }
227
228    // Parse range header (simplified - only handles single range)
229    let range_str = range_header
230        .to_str()
231        .map_err(|_| FileServingError::InvalidRange)?;
232
233    if !range_str.starts_with("bytes=") {
234        return Err(FileServingError::InvalidRange);
235    }
236
237    let range_spec = &range_str[6..]; // Skip "bytes="
238    let (start_str, end_str) = range_spec
239        .split_once('-')
240        .ok_or(FileServingError::InvalidRange)?;
241
242    // Check if this is a suffix range (e.g., "bytes=-500")
243    let is_suffix_range = start_str.is_empty();
244
245    let start: usize = if is_suffix_range {
246        // Suffix range: -500 means last 500 bytes
247        let suffix_len: usize = end_str
248            .parse()
249            .map_err(|_| FileServingError::InvalidRange)?;
250        file_size.saturating_sub(suffix_len)
251    } else {
252        start_str
253            .parse()
254            .map_err(|_| FileServingError::InvalidRange)?
255    };
256
257    let end: usize = if is_suffix_range {
258        // Suffix range always goes to the end of the file
259        file_size - 1
260    } else if end_str.is_empty() {
261        // Open-ended range: 500- means from byte 500 to end
262        file_size - 1
263    } else {
264        // Normal range with explicit end
265        end_str
266            .parse::<usize>()
267            .map_err(|_| FileServingError::InvalidRange)?
268            .min(file_size - 1)
269    };
270
271    // Validate range
272    if start > end || start >= file_size {
273        return Err(FileServingError::RangeNotSatisfiable(file_size));
274    }
275
276    let range_data = data[start..=end].to_vec();
277
278    let content_range = format!("bytes {start}-{end}/{file_size}");
279
280    Ok(build_file_response(
281        range_data,
282        etag,
283        content_type,
284        Some((&content_range, StatusCode::PARTIAL_CONTENT)),
285    ))
286}
287
288/// Build a file response with appropriate headers
289fn build_file_response(
290    data: Vec<u8>,
291    etag: &str,
292    content_type: &str,
293    range_info: Option<(&str, StatusCode)>,
294) -> Response {
295    let mut response = Response::builder();
296
297    // Set status code
298    let status = range_info.map_or(StatusCode::OK, |(_, code)| code);
299    response = response.status(status);
300
301    // Content headers
302    response = response
303        .header(CONTENT_TYPE, content_type)
304        .header(CONTENT_LENGTH, data.len())
305        .header(ETAG, etag)
306        .header(ACCEPT_RANGES, "bytes");
307
308    // Range-specific headers
309    if let Some((content_range, _)) = range_info {
310        response = response.header(CONTENT_RANGE, content_range);
311    }
312
313    // Cache headers
314    response = response
315        .header(CACHE_CONTROL, "public, max-age=86400")
316        .header(
317            LAST_MODIFIED,
318            httpdate::fmt_http_date(SystemTime::now()),
319        );
320
321    response
322        .body(Body::from(data))
323        .unwrap_or_else(|_| Response::new(Body::empty()))
324}
325
326/// Error types for file serving operations
327#[derive(Debug)]
328pub enum FileServingError {
329    /// Storage backend error
330    Storage(StorageError),
331    /// Invalid range request
332    InvalidRange,
333    /// Range not satisfiable
334    RangeNotSatisfiable(usize),
335    /// Access denied
336    AccessDenied,
337}
338
339impl std::fmt::Display for FileServingError {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        match self {
342            Self::Storage(e) => write!(f, "Storage error: {e}"),
343            Self::InvalidRange => write!(f, "Invalid range request"),
344            Self::RangeNotSatisfiable(size) => {
345                write!(f, "Range not satisfiable (file size: {size})")
346            }
347            Self::AccessDenied => write!(f, "Access denied"),
348        }
349    }
350}
351
352impl std::error::Error for FileServingError {}
353
354impl IntoResponse for FileServingError {
355    fn into_response(self) -> Response {
356        let (status, message) = match self {
357            Self::Storage(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
358            Self::InvalidRange => (StatusCode::BAD_REQUEST, self.to_string()),
359            Self::RangeNotSatisfiable(size) => {
360                let response = Response::builder()
361                    .status(StatusCode::RANGE_NOT_SATISFIABLE)
362                    .header(CONTENT_RANGE, format!("bytes */{size}"))
363                    .body(Body::from(self.to_string()))
364                    .unwrap_or_else(|_| Response::new(Body::empty()));
365                return response;
366            }
367            Self::AccessDenied => (StatusCode::FORBIDDEN, self.to_string()),
368        };
369
370        (status, message).into_response()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::storage::{LocalFileStorage, UploadedFile};
378    use tempfile::TempDir;
379
380    #[test]
381    fn test_etag_generation() {
382        let file_id = "test-file-123";
383        let data = b"Hello, World!";
384        let etag = format!(r#""{}-{}""#, file_id, data.len());
385        assert_eq!(etag, r#""test-file-123-13""#);
386    }
387
388    #[tokio::test]
389    async fn test_serve_file_uses_stored_content_type() {
390        let temp = TempDir::new().unwrap();
391        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
392
393        // Store a PDF file
394        let file = UploadedFile::new("document.pdf", "application/pdf", b"fake pdf".to_vec());
395        let stored = storage.store(file).await.unwrap();
396
397        // Serve the file
398        let headers = HeaderMap::new();
399        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
400            .await
401            .unwrap();
402
403        // Verify Content-Type header is from metadata
404        let content_type = response.headers().get(CONTENT_TYPE).unwrap();
405        assert_eq!(content_type, "application/pdf");
406    }
407
408    #[tokio::test]
409    async fn test_serve_file_uses_mime_guess_fallback() {
410        let temp = TempDir::new().unwrap();
411        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
412
413        // Store a file with generic content type
414        let file = UploadedFile::new(
415            "image.png",
416            "application/octet-stream",
417            b"fake png".to_vec(),
418        );
419        let stored = storage.store(file).await.unwrap();
420
421        // Serve the file
422        let headers = HeaderMap::new();
423        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
424            .await
425            .unwrap();
426
427        // Verify Content-Type header is guessed from extension
428        let content_type = response.headers().get(CONTENT_TYPE).unwrap();
429        assert_eq!(content_type, "image/png");
430    }
431
432    #[tokio::test]
433    async fn test_serve_file_preserves_various_content_types() {
434        let temp = TempDir::new().unwrap();
435        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
436
437        let test_cases = vec![
438            ("photo.jpg", "image/jpeg", "image/jpeg"),
439            ("video.mp4", "video/mp4", "video/mp4"),
440            ("data.json", "application/json", "application/json"),
441            ("style.css", "text/css", "text/css"),
442            ("script.js", "application/javascript", "application/javascript"),
443        ];
444
445        for (filename, stored_type, expected_type) in test_cases {
446            let file = UploadedFile::new(filename, stored_type, b"test data".to_vec());
447            let stored = storage.store(file).await.unwrap();
448
449            let headers = HeaderMap::new();
450            let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
451                .await
452                .unwrap();
453
454            let content_type = response.headers().get(CONTENT_TYPE).unwrap();
455            assert_eq!(
456                content_type,
457                expected_type,
458                "Content-Type mismatch for {filename}"
459            );
460        }
461    }
462
463    #[tokio::test]
464    async fn test_serve_file_fallback_for_unknown_extension() {
465        let temp = TempDir::new().unwrap();
466        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
467
468        // Store file with unknown extension and generic content type
469        let file = UploadedFile::new(
470            "file.unknownext",
471            "application/octet-stream",
472            b"data".to_vec(),
473        );
474        let stored = storage.store(file).await.unwrap();
475
476        let headers = HeaderMap::new();
477        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
478            .await
479            .unwrap();
480
481        // Should fallback to octet-stream
482        let content_type = response.headers().get(CONTENT_TYPE).unwrap();
483        assert_eq!(content_type, "application/octet-stream");
484    }
485
486    #[tokio::test]
487    async fn test_range_request_full_range() {
488        let temp = TempDir::new().unwrap();
489        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
490
491        // Create test file with 1000 bytes (repeating 0-255 pattern)
492        let data = (0_u8..=255).cycle().take(1000).collect::<Vec<u8>>();
493        let file = UploadedFile::new("test.bin", "application/octet-stream", data.clone());
494        let stored = storage.store(file).await.unwrap();
495
496        // Request bytes 100-199 (100 bytes)
497        let mut headers = HeaderMap::new();
498        headers.insert(RANGE, HeaderValue::from_static("bytes=100-199"));
499
500        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
501            .await
502            .unwrap();
503
504        // Verify 206 Partial Content status
505        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
506
507        // Verify Content-Range header
508        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
509        assert_eq!(content_range, "bytes 100-199/1000");
510
511        // Verify Content-Length
512        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
513        assert_eq!(content_length, "100");
514
515        // Verify ETag is present
516        assert!(response.headers().contains_key(ETAG));
517
518        // Verify Accept-Ranges header
519        assert_eq!(
520            response.headers().get(ACCEPT_RANGES).unwrap(),
521            "bytes"
522        );
523    }
524
525    #[tokio::test]
526    async fn test_range_request_suffix_range() {
527        let temp = TempDir::new().unwrap();
528        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
529
530        // Create test file with 1000 bytes (repeating 0-255 pattern)
531        let data = (0_u8..=255).cycle().take(1000).collect::<Vec<u8>>();
532        let file = UploadedFile::new("test.bin", "application/octet-stream", data.clone());
533        let stored = storage.store(file).await.unwrap();
534
535        // Request last 100 bytes (bytes=-100)
536        let mut headers = HeaderMap::new();
537        headers.insert(RANGE, HeaderValue::from_static("bytes=-100"));
538
539        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
540            .await
541            .unwrap();
542
543        // Verify 206 Partial Content status
544        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
545
546        // Verify Content-Range header (last 100 bytes: 900-999)
547        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
548        assert_eq!(content_range, "bytes 900-999/1000");
549
550        // Verify Content-Length
551        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
552        assert_eq!(content_length, "100");
553    }
554
555    #[tokio::test]
556    async fn test_range_request_suffix_range_exceeds_file_size() {
557        let temp = TempDir::new().unwrap();
558        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
559
560        // Create small file (100 bytes)
561        let data = vec![42u8; 100];
562        let file = UploadedFile::new("test.bin", "application/octet-stream", data.clone());
563        let stored = storage.store(file).await.unwrap();
564
565        // Request last 500 bytes (more than file size)
566        let mut headers = HeaderMap::new();
567        headers.insert(RANGE, HeaderValue::from_static("bytes=-500"));
568
569        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
570            .await
571            .unwrap();
572
573        // Should return entire file (saturating_sub returns 0)
574        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
575
576        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
577        assert_eq!(content_range, "bytes 0-99/100");
578    }
579
580    #[tokio::test]
581    async fn test_range_request_open_ended() {
582        let temp = TempDir::new().unwrap();
583        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
584
585        // Create test file with 1000 bytes (repeating 0-255 pattern)
586        let data = (0_u8..=255).cycle().take(1000).collect::<Vec<u8>>();
587        let file = UploadedFile::new("test.bin", "application/octet-stream", data.clone());
588        let stored = storage.store(file).await.unwrap();
589
590        // Request from byte 800 to end (bytes=800-)
591        let mut headers = HeaderMap::new();
592        headers.insert(RANGE, HeaderValue::from_static("bytes=800-"));
593
594        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
595            .await
596            .unwrap();
597
598        // Verify 206 Partial Content status
599        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
600
601        // Verify Content-Range header (bytes 800-999)
602        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
603        assert_eq!(content_range, "bytes 800-999/1000");
604
605        // Verify Content-Length
606        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
607        assert_eq!(content_length, "200");
608    }
609
610    #[tokio::test]
611    async fn test_range_request_single_byte() {
612        let temp = TempDir::new().unwrap();
613        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
614
615        let data = vec![42u8; 100];
616        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
617        let stored = storage.store(file).await.unwrap();
618
619        // Request single byte at position 50
620        let mut headers = HeaderMap::new();
621        headers.insert(RANGE, HeaderValue::from_static("bytes=50-50"));
622
623        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
624            .await
625            .unwrap();
626
627        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
628
629        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
630        assert_eq!(content_range, "bytes 50-50/100");
631
632        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
633        assert_eq!(content_length, "1");
634    }
635
636    #[tokio::test]
637    async fn test_range_request_invalid_format_no_bytes_prefix() {
638        let temp = TempDir::new().unwrap();
639        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
640
641        let data = vec![42u8; 100];
642        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
643        let stored = storage.store(file).await.unwrap();
644
645        // Invalid: missing "bytes=" prefix
646        let mut headers = HeaderMap::new();
647        headers.insert(RANGE, HeaderValue::from_static("0-99"));
648
649        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
650
651        // Should return InvalidRange error
652        assert!(response.is_err());
653        let err = response.unwrap_err();
654        assert!(matches!(err, FileServingError::InvalidRange));
655    }
656
657    #[tokio::test]
658    async fn test_range_request_invalid_format_no_dash() {
659        let temp = TempDir::new().unwrap();
660        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
661
662        let data = vec![42u8; 100];
663        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
664        let stored = storage.store(file).await.unwrap();
665
666        // Invalid: no dash separator
667        let mut headers = HeaderMap::new();
668        headers.insert(RANGE, HeaderValue::from_static("bytes=50"));
669
670        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
671
672        assert!(response.is_err());
673        let err = response.unwrap_err();
674        assert!(matches!(err, FileServingError::InvalidRange));
675    }
676
677    #[tokio::test]
678    async fn test_range_request_invalid_non_numeric() {
679        let temp = TempDir::new().unwrap();
680        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
681
682        let data = vec![42u8; 100];
683        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
684        let stored = storage.store(file).await.unwrap();
685
686        // Invalid: non-numeric values
687        let mut headers = HeaderMap::new();
688        headers.insert(RANGE, HeaderValue::from_static("bytes=abc-def"));
689
690        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
691
692        assert!(response.is_err());
693        let err = response.unwrap_err();
694        assert!(matches!(err, FileServingError::InvalidRange));
695    }
696
697    #[tokio::test]
698    async fn test_range_request_start_greater_than_end() {
699        let temp = TempDir::new().unwrap();
700        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
701
702        let data = vec![42u8; 100];
703        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
704        let stored = storage.store(file).await.unwrap();
705
706        // Invalid: start > end
707        let mut headers = HeaderMap::new();
708        headers.insert(RANGE, HeaderValue::from_static("bytes=50-20"));
709
710        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
711
712        // Should return RangeNotSatisfiable
713        assert!(response.is_err());
714        let err = response.unwrap_err();
715        assert!(matches!(err, FileServingError::RangeNotSatisfiable(100)));
716    }
717
718    #[tokio::test]
719    async fn test_range_request_start_beyond_file_size() {
720        let temp = TempDir::new().unwrap();
721        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
722
723        let data = vec![42u8; 100];
724        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
725        let stored = storage.store(file).await.unwrap();
726
727        // Invalid: start >= file size
728        let mut headers = HeaderMap::new();
729        headers.insert(RANGE, HeaderValue::from_static("bytes=100-199"));
730
731        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
732
733        // Should return RangeNotSatisfiable
734        assert!(response.is_err());
735        let err = response.unwrap_err();
736        assert!(matches!(err, FileServingError::RangeNotSatisfiable(100)));
737    }
738
739    #[tokio::test]
740    async fn test_range_request_end_exceeds_file_size() {
741        let temp = TempDir::new().unwrap();
742        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
743
744        let data = vec![42u8; 100];
745        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
746        let stored = storage.store(file).await.unwrap();
747
748        // End exceeds file size (should be clamped to file size - 1)
749        let mut headers = HeaderMap::new();
750        headers.insert(RANGE, HeaderValue::from_static("bytes=50-200"));
751
752        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
753            .await
754            .unwrap();
755
756        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
757
758        // End should be clamped to 99 (file size - 1)
759        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
760        assert_eq!(content_range, "bytes 50-99/100");
761
762        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
763        assert_eq!(content_length, "50");
764    }
765
766    #[tokio::test]
767    async fn test_range_request_with_if_range_matching_etag() {
768        let temp = TempDir::new().unwrap();
769        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
770
771        let data = vec![42u8; 100];
772        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
773        let stored = storage.store(file).await.unwrap();
774
775        // First request to get ETag
776        let headers = HeaderMap::new();
777        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
778            .await
779            .unwrap();
780        let etag = response.headers().get(ETAG).unwrap().clone();
781
782        // Range request with matching If-Range (should serve range)
783        let mut headers = HeaderMap::new();
784        headers.insert(RANGE, HeaderValue::from_static("bytes=0-49"));
785        headers.insert(IF_RANGE, etag);
786
787        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
788            .await
789            .unwrap();
790
791        // Should serve partial content
792        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
793        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
794        assert_eq!(content_range, "bytes 0-49/100");
795    }
796
797    #[tokio::test]
798    async fn test_range_request_with_if_range_non_matching_etag() {
799        let temp = TempDir::new().unwrap();
800        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
801
802        let data = vec![42u8; 100];
803        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
804        let stored = storage.store(file).await.unwrap();
805
806        // Range request with non-matching If-Range (should serve full file)
807        let mut headers = HeaderMap::new();
808        headers.insert(RANGE, HeaderValue::from_static("bytes=0-49"));
809        headers.insert(IF_RANGE, HeaderValue::from_static("\"wrong-etag\""));
810
811        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
812            .await
813            .unwrap();
814
815        // Should serve full file with 200 OK (not 206)
816        assert_eq!(response.status(), StatusCode::OK);
817        assert!(!response.headers().contains_key(CONTENT_RANGE));
818
819        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
820        assert_eq!(content_length, "100");
821    }
822
823    #[tokio::test]
824    async fn test_range_not_satisfiable_error_includes_content_range_header() {
825        let temp = TempDir::new().unwrap();
826        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
827
828        let data = vec![42u8; 100];
829        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
830        let stored = storage.store(file).await.unwrap();
831
832        // Request beyond file size
833        let mut headers = HeaderMap::new();
834        headers.insert(RANGE, HeaderValue::from_static("bytes=200-299"));
835
836        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers).await;
837
838        assert!(response.is_err());
839        let err = response.unwrap_err();
840
841        // Convert to response and verify headers
842        let response = err.into_response();
843        assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
844
845        // Should include Content-Range with file size
846        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
847        assert_eq!(content_range, "bytes */100");
848    }
849
850    #[tokio::test]
851    async fn test_range_request_preserves_cache_headers() {
852        let temp = TempDir::new().unwrap();
853        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
854
855        let data = vec![42u8; 100];
856        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
857        let stored = storage.store(file).await.unwrap();
858
859        let mut headers = HeaderMap::new();
860        headers.insert(RANGE, HeaderValue::from_static("bytes=0-49"));
861
862        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
863            .await
864            .unwrap();
865
866        // Verify cache headers are present in range responses
867        assert!(response.headers().contains_key(ETAG));
868        assert!(response.headers().contains_key(CACHE_CONTROL));
869        assert!(response.headers().contains_key(LAST_MODIFIED));
870    }
871
872    #[tokio::test]
873    async fn test_no_range_header_serves_full_file() {
874        let temp = TempDir::new().unwrap();
875        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
876
877        let data = vec![42u8; 100];
878        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
879        let stored = storage.store(file).await.unwrap();
880
881        // No Range header - should serve full file
882        let headers = HeaderMap::new();
883
884        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
885            .await
886            .unwrap();
887
888        assert_eq!(response.status(), StatusCode::OK);
889        assert!(!response.headers().contains_key(CONTENT_RANGE));
890
891        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
892        assert_eq!(content_length, "100");
893
894        // Should still advertise range support
895        assert_eq!(
896            response.headers().get(ACCEPT_RANGES).unwrap(),
897            "bytes"
898        );
899    }
900
901    #[tokio::test]
902    async fn test_range_request_first_byte() {
903        let temp = TempDir::new().unwrap();
904        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
905
906        let data = vec![42u8; 100];
907        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
908        let stored = storage.store(file).await.unwrap();
909
910        // Request first byte only
911        let mut headers = HeaderMap::new();
912        headers.insert(RANGE, HeaderValue::from_static("bytes=0-0"));
913
914        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
915            .await
916            .unwrap();
917
918        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
919
920        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
921        assert_eq!(content_range, "bytes 0-0/100");
922
923        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
924        assert_eq!(content_length, "1");
925    }
926
927    #[tokio::test]
928    async fn test_range_request_last_byte() {
929        let temp = TempDir::new().unwrap();
930        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
931
932        let data = vec![42u8; 100];
933        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
934        let stored = storage.store(file).await.unwrap();
935
936        // Request last byte only
937        let mut headers = HeaderMap::new();
938        headers.insert(RANGE, HeaderValue::from_static("bytes=99-99"));
939
940        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
941            .await
942            .unwrap();
943
944        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
945
946        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
947        assert_eq!(content_range, "bytes 99-99/100");
948
949        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
950        assert_eq!(content_length, "1");
951    }
952
953    #[tokio::test]
954    async fn test_range_request_entire_file_as_range() {
955        let temp = TempDir::new().unwrap();
956        let storage = Arc::new(LocalFileStorage::new(temp.path().to_path_buf()).unwrap());
957
958        let data = vec![42u8; 100];
959        let file = UploadedFile::new("test.bin", "application/octet-stream", data);
960        let stored = storage.store(file).await.unwrap();
961
962        // Request entire file as range
963        let mut headers = HeaderMap::new();
964        headers.insert(RANGE, HeaderValue::from_static("bytes=0-99"));
965
966        let response = serve_file(State(storage.clone()), Path(stored.id.clone()), headers)
967            .await
968            .unwrap();
969
970        // Should still return 206 Partial Content (RFC 7233)
971        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
972
973        let content_range = response.headers().get(CONTENT_RANGE).unwrap();
974        assert_eq!(content_range, "bytes 0-99/100");
975
976        let content_length = response.headers().get(CONTENT_LENGTH).unwrap();
977        assert_eq!(content_length, "100");
978    }
979}