systemprompt_api/services/static_content/static_files/
cache.rs1use axum::http::{HeaderMap, StatusCode, header};
14use axum::response::IntoResponse;
15use std::hash::{Hash, Hasher};
16use std::path::Path;
17use systemprompt_models::api::ApiError;
18
19use crate::error::ApiHttpError;
20
21pub const CACHE_STATIC_ASSET: &str = "public, max-age=31536000, immutable";
22pub const CACHE_STATIC_ASSET_REVALIDATE: &str = "public, max-age=0, must-revalidate";
23pub const CACHE_HTML: &str = "no-cache";
24pub const CACHE_METADATA: &str = "public, max-age=3600";
25
26const CONTENT_HASH_MIN_LEN: usize = 8;
27const CONTENT_HASH_MAX_LEN: usize = 32;
28
29pub fn asset_cache_policy(path: &Path) -> &'static str {
30 let hashed = path
31 .file_name()
32 .and_then(|name| name.to_str())
33 .is_some_and(has_content_hash_segment);
34
35 if hashed {
36 CACHE_STATIC_ASSET
37 } else {
38 CACHE_STATIC_ASSET_REVALIDATE
39 }
40}
41
42fn has_content_hash_segment(file_name: &str) -> bool {
43 let segments: Vec<&str> = file_name.split(['.', '-']).collect();
44 let Some(interior) = segments.get(1..segments.len().saturating_sub(1)) else {
45 return false;
46 };
47 interior.iter().copied().any(is_content_hash)
48}
49
50fn is_content_hash(segment: &str) -> bool {
51 if !(CONTENT_HASH_MIN_LEN..=CONTENT_HASH_MAX_LEN).contains(&segment.len())
52 || !segment.chars().all(|c| c.is_ascii_alphanumeric())
53 {
54 return false;
55 }
56 let all_hex = segment.chars().all(|c| c.is_ascii_hexdigit());
57 let mixed = segment.chars().any(|c| c.is_ascii_digit())
58 && segment.chars().any(|c| c.is_ascii_alphabetic());
59 all_hex || mixed
60}
61
62pub fn compute_etag(content: &[u8]) -> String {
63 let mut hasher = std::collections::hash_map::DefaultHasher::new();
64 content.hash(&mut hasher);
65 format!("\"{}\"", hasher.finish())
66}
67
68pub(super) fn etag_matches(headers: &HeaderMap, etag: &str) -> bool {
69 let Some(value) = headers
70 .get(header::IF_NONE_MATCH)
71 .and_then(|v| v.to_str().ok())
72 else {
73 return false;
74 };
75 let opaque = |tag: &str| tag.trim().trim_start_matches("W/").to_owned();
76 let current = opaque(etag);
77 value
78 .split(',')
79 .any(|candidate| candidate.trim() == "*" || opaque(candidate) == current)
80}
81
82pub(super) fn if_range_holds(headers: &HeaderMap, etag: Option<&str>) -> bool {
83 let Some(value) = headers.get(header::IF_RANGE).and_then(|v| v.to_str().ok()) else {
84 return true;
85 };
86 let value = value.trim();
87 etag.is_some_and(|etag| !etag.starts_with("W/") && !value.starts_with("W/") && value == etag)
88}
89
90pub(super) fn not_modified_response(etag: &str, cache_control: &str) -> axum::response::Response {
91 (
92 StatusCode::NOT_MODIFIED,
93 [
94 (header::ETAG, etag.to_owned()),
95 (header::CACHE_CONTROL, cache_control.to_owned()),
96 ],
97 )
98 .into_response()
99}
100
101fn serve_file_response(
102 content: Vec<u8>,
103 content_type: String,
104 cache_control: &str,
105 etag: String,
106) -> axum::response::Response {
107 (
108 StatusCode::OK,
109 [
110 (header::CONTENT_TYPE, content_type),
111 (header::CACHE_CONTROL, cache_control.to_owned()),
112 (header::ETAG, etag),
113 ],
114 content,
115 )
116 .into_response()
117}
118
119pub(super) async fn serve_cached_file(
120 file_path: &Path,
121 headers: &HeaderMap,
122 content_type: &str,
123 cache_control: &str,
124) -> axum::response::Response {
125 match tokio::fs::read(file_path).await {
126 Ok(content) => {
127 let etag = compute_etag(&content);
128 if etag_matches(headers, &etag) {
129 return not_modified_response(&etag, cache_control);
130 }
131 serve_file_response(content, content_type.to_owned(), cache_control, etag)
132 },
133 Err(e) => ApiHttpError::from(ApiError::internal("Error reading file", e)).into_response(),
134 }
135}