Skip to main content

systemprompt_api/services/static_content/static_files/
cache.rs

1//! Cache-control constants and `ETag` computation/matching for static file
2//! responses.
3//!
4//! Assets are classified by URL shape, not by how they were built, so
5//! `/css/content.css` and `/css/content.4f3a9c1e.css` reach the same handler.
6//! Only a name carrying a content hash may be answered with `immutable`: a
7//! client that believes that promise will not revalidate for the lifetime of
8//! the `max-age`, leaving the `ETag` computed here unreachable.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use axum::http::{HeaderMap, StatusCode, header};
14use axum::response::IntoResponse;
15use std::hash::{Hash, Hasher};
16use std::path::Path;
17
18pub const CACHE_STATIC_ASSET: &str = "public, max-age=31536000, immutable";
19pub const CACHE_STATIC_ASSET_REVALIDATE: &str = "public, max-age=0, must-revalidate";
20pub const CACHE_HTML: &str = "no-cache";
21pub const CACHE_METADATA: &str = "public, max-age=3600";
22
23const CONTENT_HASH_MIN_LEN: usize = 8;
24const CONTENT_HASH_MAX_LEN: usize = 32;
25
26pub fn asset_cache_policy(path: &Path) -> &'static str {
27    let hashed = path
28        .file_name()
29        .and_then(|name| name.to_str())
30        .is_some_and(has_content_hash_segment);
31
32    if hashed {
33        CACHE_STATIC_ASSET
34    } else {
35        CACHE_STATIC_ASSET_REVALIDATE
36    }
37}
38
39fn has_content_hash_segment(file_name: &str) -> bool {
40    let segments: Vec<&str> = file_name.split(['.', '-']).collect();
41    let Some(interior) = segments.get(1..segments.len().saturating_sub(1)) else {
42        return false;
43    };
44    interior.iter().copied().any(is_content_hash)
45}
46
47fn is_content_hash(segment: &str) -> bool {
48    if !(CONTENT_HASH_MIN_LEN..=CONTENT_HASH_MAX_LEN).contains(&segment.len())
49        || !segment.chars().all(|c| c.is_ascii_alphanumeric())
50    {
51        return false;
52    }
53    let all_hex = segment.chars().all(|c| c.is_ascii_hexdigit());
54    let mixed = segment.chars().any(|c| c.is_ascii_digit())
55        && segment.chars().any(|c| c.is_ascii_alphabetic());
56    all_hex || mixed
57}
58
59pub fn compute_etag(content: &[u8]) -> String {
60    let mut hasher = std::collections::hash_map::DefaultHasher::new();
61    content.hash(&mut hasher);
62    format!("\"{}\"", hasher.finish())
63}
64
65pub(super) fn etag_matches(headers: &HeaderMap, etag: &str) -> bool {
66    headers
67        .get(header::IF_NONE_MATCH)
68        .and_then(|v| v.to_str().ok())
69        == Some(etag)
70}
71
72pub(super) fn not_modified_response(etag: &str, cache_control: &str) -> axum::response::Response {
73    (
74        StatusCode::NOT_MODIFIED,
75        [
76            (header::ETAG, etag.to_owned()),
77            (header::CACHE_CONTROL, cache_control.to_owned()),
78        ],
79    )
80        .into_response()
81}
82
83fn serve_file_response(
84    content: Vec<u8>,
85    content_type: String,
86    cache_control: &str,
87    etag: String,
88) -> axum::response::Response {
89    (
90        StatusCode::OK,
91        [
92            (header::CONTENT_TYPE, content_type),
93            (header::CACHE_CONTROL, cache_control.to_owned()),
94            (header::ETAG, etag),
95        ],
96        content,
97    )
98        .into_response()
99}
100
101pub(super) async fn serve_cached_file(
102    file_path: &Path,
103    headers: &HeaderMap,
104    content_type: &str,
105    cache_control: &str,
106) -> axum::response::Response {
107    match tokio::fs::read(file_path).await {
108        Ok(content) => {
109            let etag = compute_etag(&content);
110            if etag_matches(headers, &etag) {
111                return not_modified_response(&etag, cache_control);
112            }
113            serve_file_response(content, content_type.to_owned(), cache_control, etag)
114        },
115        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(),
116    }
117}