Skip to main content

anycms_spa/core/
mod.rs

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/// SPA 配置
22#[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
76/// SPA 响应数据
77pub 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    /// 是否有压缩变体可用
90    #[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    /// 根据 Accept-Encoding 选择最优编码,返回 (Content-Encoding 值, 压缩数据)
104    /// 优先级:br > gzip
105    #[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/// 判断 MIME 类型是否值得压缩
128#[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
141/// 对文本类 MIME 类型追加 `; charset=utf-8`
142pub 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
156/// 格式化 ETag:取 SHA256 前 16 字节 hex 编码,加双引号
157fn format_etag(hash: &[u8; 32]) -> String {
158    let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
159    format!("\"{}\"", hex)
160}
161
162/// 检查 Accept-Encoding 是否包含 gzip
163pub fn accepts_gzip(accept_encoding: &str) -> bool {
164    accept_encoding.contains("gzip")
165}
166
167/// 检查 Accept-Encoding 是否包含 br(Brotli)
168pub fn accepts_brotli(accept_encoding: &str) -> bool {
169    accept_encoding.contains("br")
170}
171
172/// 检查 If-None-Match 是否匹配当前 ETag
173pub 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/// 解析后的 Range 请求
183#[derive(Debug, Clone, PartialEq)]
184pub struct RangeSpec {
185    pub start: usize,
186    pub end: usize,
187}
188
189impl RangeSpec {
190    /// 解析 `Range: bytes=0-1023` 头,返回 RangeSpec 或 None
191    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        // 暂只支持单段 Range
197        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            // bytes=start-end
208            (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            // bytes=start- (from start to end)
220            (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            // bytes=-suffix (last N bytes)
231            ("", 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    /// 格式化 Content-Range 头值
249    pub fn content_range(&self, total_len: usize) -> String {
250        format!("bytes {}-{}/{}", self.start, self.end, total_len)
251    }
252
253    /// 获取切片范围长度
254    pub fn len(&self) -> usize {
255        self.end - self.start + 1
256    }
257}
258
259/// 检查 If-Range 是否匹配(支持 ETag 或 HTTP-date,这里只处理 ETag)
260pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
261    let if_range = if_range.trim();
262    // ETag 形式:以双引号开头
263    if if_range.starts_with('"') {
264        if_range == etag
265    } else {
266        // HTTP-date 形式:暂不支持,跳过 If-Range 检查
267        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
295/// SPA 处理器
296pub 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    /// 获取嵌入的文件(考虑基础路径)
353    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        // SPA fallback:尝试索引文件
383        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    /// 获取自定义错误页面
409    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); // 16 hex chars + 2 quotes
459    }
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        // bytes=0-4
509        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        // bytes=5-
517        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        // bytes=-3
525        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()); // start > end
533        assert!(RangeSpec::parse("bytes=10-", 10).is_none()); // start >= total
534        assert!(RangeSpec::parse("bytes=abc-4", 10).is_none()); // non-numeric
535        assert!(RangeSpec::parse("chunks=0-4", 10).is_none()); // wrong unit
536        assert!(RangeSpec::parse("", 10).is_none());
537    }
538
539    #[test]
540    fn test_range_parse_clamp_end() {
541        // end beyond total
542        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        // HTTP-date form -> not supported, returns false
558        assert!(!if_range_matches("Sun, 24 May 2026 00:00:00 GMT", etag));
559    }
560}