1pub mod path;
2
3use rust_embed::RustEmbed;
4use std::borrow::Cow;
5use std::collections::HashMap;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum SpaError {
11 #[error("Resource not found: {0}")]
12 NotFound(String),
13 #[error("MIME type detection failed")]
14 MimeDetection,
15 #[error("Path error: {0}")]
16 PathError(#[from] crate::core::path::PathError),
17 #[error("Index file not found")]
18 IndexFileNotFound,
19}
20
21#[derive(Clone)]
23#[non_exhaustive]
24pub struct SpaConfig {
25 pub base_path: String,
26 pub index_files: Vec<String>,
27 pub security_headers: Vec<(String, String)>,
28 pub error_pages: HashMap<u16, String>,
29}
30
31impl Default for SpaConfig {
32 fn default() -> Self {
33 SpaConfig {
34 base_path: "/".to_string(),
35 index_files: vec!["index.html".to_string()],
36 security_headers: Vec::new(),
37 error_pages: HashMap::new(),
38 }
39 }
40}
41
42impl SpaConfig {
43 pub fn with_base_path(mut self, base_path: &str) -> Self {
44 self.base_path = base_path.to_string();
45 self
46 }
47
48 pub fn with_index_files(mut self, files: &[&str]) -> Self {
49 self.index_files = files.iter().map(|s| s.to_string()).collect();
50 self
51 }
52
53 pub fn add_index_file(mut self, file: &str) -> Self {
54 self.index_files.push(file.to_string());
55 self
56 }
57
58 pub fn with_error_page(mut self, status_code: u16, file_path: &str) -> Self {
59 self.error_pages.insert(status_code, file_path.to_string());
60 self
61 }
62
63 pub fn with_security_header(mut self, key: &str, value: &str) -> Self {
64 self.security_headers.push((key.to_string(), value.to_string()));
65 self
66 }
67
68 pub fn with_default_security_headers(self) -> Self {
69 self.with_security_header("X-Content-Type-Options", "nosniff")
70 .with_security_header("X-Frame-Options", "SAMEORIGIN")
71 .with_security_header("X-XSS-Protection", "1; mode=block")
72 .with_security_header("Referrer-Policy", "strict-origin-when-cross-origin")
73 }
74}
75
76pub struct SpaResponse {
78 pub data: Cow<'static, [u8]>,
79 pub mime: &'static str,
80 pub etag: String,
81 pub is_html: bool,
82 #[cfg(feature = "gzip")]
83 pub gzip_data: Option<Vec<u8>>,
84 #[cfg(feature = "brotli")]
85 pub brotli_data: Option<Vec<u8>>,
86}
87
88impl SpaResponse {
89 #[cfg(any(feature = "gzip", feature = "brotli"))]
91 pub fn has_compression(&self) -> bool {
92 #[cfg(feature = "gzip")]
93 if self.gzip_data.is_some() {
94 return true;
95 }
96 #[cfg(feature = "brotli")]
97 if self.brotli_data.is_some() {
98 return true;
99 }
100 false
101 }
102
103 #[cfg(any(feature = "gzip", feature = "brotli"))]
106 pub fn select_encoding(&self, accept_encoding: &str) -> Option<(&'static str, &[u8])> {
107 #[cfg(feature = "brotli")]
108 {
109 if accepts_brotli(accept_encoding) {
110 if let Some(ref data) = self.brotli_data {
111 return Some(("br", data.as_slice()));
112 }
113 }
114 }
115 #[cfg(feature = "gzip")]
116 {
117 if accepts_gzip(accept_encoding) {
118 if let Some(ref data) = self.gzip_data {
119 return Some(("gzip", data.as_slice()));
120 }
121 }
122 }
123 None
124 }
125}
126
127#[cfg(any(feature = "gzip", feature = "brotli"))]
129fn is_compressible(mime: &str) -> bool {
130 const COMPRESSIBLE: &[&str] = &[
131 "text/",
132 "application/javascript",
133 "application/json",
134 "application/xml",
135 "application/wasm",
136 "image/svg+xml",
137 ];
138 COMPRESSIBLE.iter().any(|prefix| mime.starts_with(prefix))
139}
140
141pub fn content_type_with_charset(mime: &str) -> Cow<'static, str> {
143 const CHARSET_TYPES: &[&str] = &[
144 "text/",
145 "application/javascript",
146 "application/json",
147 "application/xml",
148 ];
149 if CHARSET_TYPES.iter().any(|prefix| mime.starts_with(prefix)) {
150 format!("{}; charset=utf-8", mime).into()
151 } else {
152 mime.to_string().into()
153 }
154}
155
156fn format_etag(hash: &[u8; 32]) -> String {
158 let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
159 format!("\"{}\"", hex)
160}
161
162pub fn accepts_gzip(accept_encoding: &str) -> bool {
164 accept_encoding.contains("gzip")
165}
166
167pub fn accepts_brotli(accept_encoding: &str) -> bool {
169 accept_encoding.contains("br")
170}
171
172pub fn etag_matches(if_none_match: &str, etag: &str) -> bool {
174 if if_none_match.trim() == "*" {
175 return true;
176 }
177 if_none_match
178 .split(',')
179 .any(|tag| tag.trim() == etag)
180}
181
182#[derive(Debug, Clone, PartialEq)]
184pub struct RangeSpec {
185 pub start: usize,
186 pub end: usize,
187}
188
189impl RangeSpec {
190 pub fn parse(range_header: &str, total_len: usize) -> Option<Self> {
192 let range_header = range_header.trim();
193 let suffix = range_header.strip_prefix("bytes=")?;
194 let suffix = suffix.trim();
195
196 if suffix.contains(',') {
198 return None;
199 }
200
201 let parts: Vec<&str> = suffix.splitn(2, '-').collect();
202 if parts.len() != 2 {
203 return None;
204 }
205
206 let spec = match (parts[0].trim(), parts[1].trim()) {
207 (start_s, end_s) if !start_s.is_empty() && !end_s.is_empty() => {
209 let start: usize = start_s.parse().ok()?;
210 let end: usize = end_s.parse().ok()?;
211 if start > end || start >= total_len {
212 return None;
213 }
214 RangeSpec {
215 start,
216 end: end.min(total_len - 1),
217 }
218 }
219 (start_s, "") if !start_s.is_empty() => {
221 let start: usize = start_s.parse().ok()?;
222 if start >= total_len {
223 return None;
224 }
225 RangeSpec {
226 start,
227 end: total_len - 1,
228 }
229 }
230 ("", suffix_s) if !suffix_s.is_empty() => {
232 let suffix_len: usize = suffix_s.parse().ok()?;
233 if suffix_len == 0 {
234 return None;
235 }
236 let start = total_len.saturating_sub(suffix_len);
237 RangeSpec {
238 start,
239 end: total_len - 1,
240 }
241 }
242 _ => return None,
243 };
244
245 Some(spec)
246 }
247
248 pub fn content_range(&self, total_len: usize) -> String {
250 format!("bytes {}-{}/{}", self.start, self.end, total_len)
251 }
252
253 pub fn len(&self) -> usize {
255 self.end - self.start + 1
256 }
257}
258
259pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
261 let if_range = if_range.trim();
262 if if_range.starts_with('"') {
264 if_range == etag
265 } else {
266 false
268 }
269}
270
271#[cfg(feature = "gzip")]
272fn gzip_compress(data: &[u8]) -> Vec<u8> {
273 use flate2::write::GzEncoder;
274 use flate2::Compression;
275 use std::io::Write;
276
277 let mut encoder = GzEncoder::new(Vec::with_capacity(data.len() / 2), Compression::fast());
278 encoder.write_all(data).expect("gzip compression failed");
279 encoder.finish().expect("gzip finalization failed")
280}
281
282#[cfg(feature = "brotli")]
283fn brotli_compress(data: &[u8]) -> Vec<u8> {
284 use std::io::Write;
285 let mut compressor = brotli::CompressorWriter::new(
286 Vec::with_capacity(data.len() / 2),
287 4096,
288 4,
289 22,
290 );
291 compressor.write_all(data).expect("brotli compression failed");
292 compressor.into_inner()
293}
294
295pub struct SpaHandler<E: RustEmbed> {
297 config: SpaConfig,
298 #[cfg(feature = "gzip")]
299 compression_cache: HashMap<String, Vec<u8>>,
300 #[cfg(feature = "brotli")]
301 brotli_cache: HashMap<String, Vec<u8>>,
302 _marker: std::marker::PhantomData<E>,
303}
304
305impl<E: RustEmbed> SpaHandler<E> {
306 pub fn new(config: SpaConfig) -> Self {
307 #[cfg(feature = "gzip")]
308 let mut gzip_cache = HashMap::new();
309 #[cfg(feature = "brotli")]
310 let mut br_cache = HashMap::new();
311
312 #[cfg(any(feature = "gzip", feature = "brotli"))]
313 for path in E::iter() {
314 let path_str = path.as_ref();
315 if let Some(file) = E::get(path_str) {
316 let mime = mime_guess::from_path(path_str)
317 .first_raw()
318 .unwrap_or("");
319 if is_compressible(mime) {
320 #[cfg(feature = "gzip")]
321 {
322 let compressed = gzip_compress(&file.data);
323 if compressed.len() < file.data.len() {
324 gzip_cache.insert(path_str.to_string(), compressed);
325 }
326 }
327 #[cfg(feature = "brotli")]
328 {
329 let compressed = brotli_compress(&file.data);
330 if compressed.len() < file.data.len() {
331 br_cache.insert(path_str.to_string(), compressed);
332 }
333 }
334 }
335 }
336 }
337
338 Self {
339 config,
340 #[cfg(feature = "gzip")]
341 compression_cache: gzip_cache,
342 #[cfg(feature = "brotli")]
343 brotli_cache: br_cache,
344 _marker: std::marker::PhantomData,
345 }
346 }
347
348 pub fn security_headers(&self) -> &[(String, String)] {
349 &self.config.security_headers
350 }
351
352 pub fn get_file(&self, request_path: &str) -> Result<SpaResponse, SpaError> {
354 let clean_path = crate::core::path::collapse_slashes(request_path);
355 let normalized_path = crate::core::path::normalize_path(&clean_path)?;
356 let resource_path = crate::core::path::relative_to_base(&normalized_path, &self.config.base_path);
357
358 if let Some(content) = E::get(&resource_path) {
359 let mime = mime_guess::from_path(&resource_path)
360 .first_raw()
361 .unwrap_or("application/octet-stream");
362 let is_html = mime.starts_with("text/html");
363 let etag = format_etag(&content.metadata.sha256_hash());
364
365 #[cfg(feature = "gzip")]
366 let gzip_data = self.compression_cache.get(&resource_path).cloned();
367 #[cfg(feature = "brotli")]
368 let brotli_data = self.brotli_cache.get(&resource_path).cloned();
369
370 return Ok(SpaResponse {
371 data: content.data,
372 mime,
373 etag,
374 is_html,
375 #[cfg(feature = "gzip")]
376 gzip_data,
377 #[cfg(feature = "brotli")]
378 brotli_data,
379 });
380 }
381
382 for index_file in &self.config.index_files {
384 if let Some(content) = E::get(index_file) {
385 let etag = format_etag(&content.metadata.sha256_hash());
386
387 #[cfg(feature = "gzip")]
388 let gzip_data = self.compression_cache.get(index_file).cloned();
389 #[cfg(feature = "brotli")]
390 let brotli_data = self.brotli_cache.get(index_file).cloned();
391
392 return Ok(SpaResponse {
393 data: content.data,
394 mime: "text/html",
395 etag,
396 is_html: true,
397 #[cfg(feature = "gzip")]
398 gzip_data,
399 #[cfg(feature = "brotli")]
400 brotli_data,
401 });
402 }
403 }
404
405 Err(SpaError::IndexFileNotFound)
406 }
407
408 pub fn get_error_page(&self, status: u16) -> Option<SpaResponse> {
410 let file_path = self.config.error_pages.get(&status)?;
411 let content = E::get(file_path)?;
412 let mime = mime_guess::from_path(file_path.as_str())
413 .first_raw()
414 .unwrap_or("text/html");
415 let etag = format_etag(&content.metadata.sha256_hash());
416
417 #[cfg(feature = "gzip")]
418 let gzip_data = self.compression_cache.get(file_path).cloned();
419 #[cfg(feature = "brotli")]
420 let brotli_data = self.brotli_cache.get(file_path).cloned();
421
422 Some(SpaResponse {
423 data: content.data,
424 mime,
425 etag,
426 is_html: mime.starts_with("text/html"),
427 #[cfg(feature = "gzip")]
428 gzip_data,
429 #[cfg(feature = "brotli")]
430 brotli_data,
431 })
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[cfg(feature = "gzip")]
440 #[test]
441 fn test_is_compressible() {
442 assert!(is_compressible("text/html"));
443 assert!(is_compressible("text/css"));
444 assert!(is_compressible("application/javascript"));
445 assert!(is_compressible("application/json"));
446 assert!(is_compressible("image/svg+xml"));
447 assert!(!is_compressible("image/png"));
448 assert!(!is_compressible("image/jpeg"));
449 assert!(!is_compressible("font/woff2"));
450 }
451
452 #[test]
453 fn test_format_etag() {
454 let hash = [0xab; 32];
455 let etag = format_etag(&hash);
456 assert!(etag.starts_with('"'));
457 assert!(etag.ends_with('"'));
458 assert_eq!(etag.len(), 34); }
460
461 #[test]
462 fn test_etag_matches() {
463 let etag = "\"abc123\"";
464 assert!(etag_matches("\"abc123\"", etag));
465 assert!(etag_matches("\"abc123\", \"def456\"", etag));
466 assert!(etag_matches("*", etag));
467 assert!(!etag_matches("\"def456\"", etag));
468 }
469
470 #[test]
471 fn test_accepts_gzip() {
472 assert!(accepts_gzip("gzip"));
473 assert!(accepts_gzip("gzip, deflate, br"));
474 assert!(accepts_gzip("deflate, gzip"));
475 assert!(!accepts_gzip("deflate, br"));
476 assert!(!accepts_gzip(""));
477 }
478
479 #[test]
480 fn test_content_type_with_charset() {
481 assert_eq!(content_type_with_charset("text/html"), "text/html; charset=utf-8");
482 assert_eq!(content_type_with_charset("text/css"), "text/css; charset=utf-8");
483 assert_eq!(content_type_with_charset("application/javascript"), "application/javascript; charset=utf-8");
484 assert_eq!(content_type_with_charset("application/json"), "application/json; charset=utf-8");
485 assert_eq!(content_type_with_charset("image/png"), "image/png");
486 assert_eq!(content_type_with_charset("application/wasm"), "application/wasm");
487 }
488
489 #[test]
490 fn test_accepts_brotli() {
491 assert!(accepts_brotli("br"));
492 assert!(accepts_brotli("gzip, deflate, br"));
493 assert!(accepts_brotli("br, gzip"));
494 assert!(!accepts_brotli("gzip, deflate"));
495 assert!(!accepts_brotli(""));
496 }
497
498 #[cfg(feature = "brotli")]
499 #[test]
500 fn test_brotli_compress() {
501 let data = b"hello world hello world hello world hello world";
502 let compressed = brotli_compress(data);
503 assert!(compressed.len() < data.len());
504 }
505
506 #[test]
507 fn test_range_parse_start_end() {
508 let spec = RangeSpec::parse("bytes=0-4", 10).unwrap();
510 assert_eq!(spec, RangeSpec { start: 0, end: 4 });
511 assert_eq!(spec.len(), 5);
512 }
513
514 #[test]
515 fn test_range_parse_start_open() {
516 let spec = RangeSpec::parse("bytes=5-", 10).unwrap();
518 assert_eq!(spec, RangeSpec { start: 5, end: 9 });
519 assert_eq!(spec.len(), 5);
520 }
521
522 #[test]
523 fn test_range_parse_suffix() {
524 let spec = RangeSpec::parse("bytes=-3", 10).unwrap();
526 assert_eq!(spec, RangeSpec { start: 7, end: 9 });
527 assert_eq!(spec.len(), 3);
528 }
529
530 #[test]
531 fn test_range_parse_invalid() {
532 assert!(RangeSpec::parse("bytes=5-3", 10).is_none()); assert!(RangeSpec::parse("bytes=10-", 10).is_none()); assert!(RangeSpec::parse("bytes=abc-4", 10).is_none()); assert!(RangeSpec::parse("chunks=0-4", 10).is_none()); assert!(RangeSpec::parse("", 10).is_none());
537 }
538
539 #[test]
540 fn test_range_parse_clamp_end() {
541 let spec = RangeSpec::parse("bytes=0-999", 100).unwrap();
543 assert_eq!(spec, RangeSpec { start: 0, end: 99 });
544 }
545
546 #[test]
547 fn test_range_content_range() {
548 let spec = RangeSpec { start: 0, end: 4 };
549 assert_eq!(spec.content_range(10), "bytes 0-4/10");
550 }
551
552 #[test]
553 fn test_if_range_matches() {
554 let etag = "\"abc123\"";
555 assert!(if_range_matches("\"abc123\"", etag));
556 assert!(!if_range_matches("\"def456\"", etag));
557 assert!(!if_range_matches("Sun, 24 May 2026 00:00:00 GMT", etag));
559 }
560}