Skip to main content

systemprompt_api/services/static_content/
static_files.rs

1use axum::extract::State;
2use axum::http::{HeaderMap, StatusCode, Uri, header};
3use axum::response::IntoResponse;
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6
7use super::config::StaticContentMatcher;
8use systemprompt_content::ContentRepository;
9use systemprompt_files::FilesConfig;
10use systemprompt_identifiers::SourceId;
11use systemprompt_models::{AppPaths, RouteClassifier, RouteType};
12use systemprompt_runtime::AppContext;
13
14#[derive(Clone, Debug)]
15pub struct StaticContentState {
16    pub ctx: Arc<AppContext>,
17    pub matcher: Arc<StaticContentMatcher>,
18    pub route_classifier: Arc<RouteClassifier>,
19}
20
21pub const CACHE_STATIC_ASSET: &str = "public, max-age=31536000, immutable";
22pub const CACHE_HTML: &str = "no-cache";
23pub const CACHE_METADATA: &str = "public, max-age=3600";
24
25pub fn compute_etag(content: &[u8]) -> String {
26    let mut hasher = std::collections::hash_map::DefaultHasher::new();
27    content.hash(&mut hasher);
28    format!("\"{}\"", hasher.finish())
29}
30
31fn etag_matches(headers: &HeaderMap, etag: &str) -> bool {
32    headers
33        .get(header::IF_NONE_MATCH)
34        .and_then(|v| v.to_str().ok())
35        == Some(etag)
36}
37
38fn not_modified_response(etag: &str, cache_control: &'static str) -> axum::response::Response {
39    (
40        StatusCode::NOT_MODIFIED,
41        [
42            (header::ETAG, etag.to_string()),
43            (header::CACHE_CONTROL, cache_control.to_string()),
44        ],
45    )
46        .into_response()
47}
48
49fn serve_file_response(
50    content: Vec<u8>,
51    content_type: String,
52    cache_control: &'static str,
53    etag: String,
54) -> axum::response::Response {
55    (
56        StatusCode::OK,
57        [
58            (header::CONTENT_TYPE, content_type),
59            (header::CACHE_CONTROL, cache_control.to_string()),
60            (header::ETAG, etag),
61        ],
62        content,
63    )
64        .into_response()
65}
66
67async fn serve_cached_file(
68    file_path: &std::path::Path,
69    headers: &HeaderMap,
70    content_type: &str,
71    cache_control: &'static str,
72) -> axum::response::Response {
73    match tokio::fs::read(file_path).await {
74        Ok(content) => {
75            let etag = compute_etag(&content);
76            if etag_matches(headers, &etag) {
77                return not_modified_response(&etag, cache_control);
78            }
79            serve_file_response(content, content_type.to_string(), cache_control, etag)
80        },
81        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(),
82    }
83}
84
85fn resolve_mime_type(path: &std::path::Path) -> &'static str {
86    match path.extension().and_then(|ext| ext.to_str()) {
87        Some("js") => "application/javascript",
88        Some("css") => "text/css",
89        Some("woff" | "woff2") => "font/woff2",
90        Some("ttf") => "font/ttf",
91        Some("png") => "image/png",
92        Some("jpg" | "jpeg") => "image/jpeg",
93        Some("svg") => "image/svg+xml",
94        Some("ico") => "image/x-icon",
95        Some("json") => "application/json",
96        Some("pdf") => "application/pdf",
97        _ => "application/octet-stream",
98    }
99}
100
101pub async fn serve_static_content(
102    State(state): State<StaticContentState>,
103    uri: Uri,
104    headers: HeaderMap,
105    _req_ctx: Option<axum::Extension<systemprompt_models::RequestContext>>,
106) -> impl IntoResponse {
107    let dist_dir = match AppPaths::get() {
108        Ok(paths) => paths.web().dist().to_path_buf(),
109        Err(_) => {
110            return (
111                StatusCode::INTERNAL_SERVER_ERROR,
112                "AppPaths not initialized",
113            )
114                .into_response();
115        },
116    };
117
118    let path = uri.path();
119
120    if matches!(
121        state.route_classifier.classify(path, "GET"),
122        RouteType::StaticAsset { .. }
123    ) {
124        return serve_static_asset(path, &dist_dir, &headers).await;
125    }
126
127    if path == "/" {
128        return serve_cached_file(
129            &dist_dir.join("index.html"),
130            &headers,
131            "text/html",
132            CACHE_HTML,
133        )
134        .await;
135    }
136
137    if matches!(
138        path,
139        "/sitemap.xml" | "/robots.txt" | "/llms.txt" | "/feed.xml"
140    ) {
141        return serve_metadata_file(path, &dist_dir, &headers).await;
142    }
143
144    let trimmed_path = path.trim_start_matches('/');
145    let parent_route_path = dist_dir.join(trimmed_path).join("index.html");
146    if parent_route_path.exists() {
147        return serve_cached_file(&parent_route_path, &headers, "text/html", CACHE_HTML).await;
148    }
149
150    if let Some((slug, source_id)) = state.matcher.matches(path) {
151        let req = ContentPageRequest {
152            path,
153            trimmed_path,
154            slug: &slug,
155            source_id: &source_id,
156            dist_dir: &dist_dir,
157            headers: &headers,
158        };
159        return serve_content_page(req, &state.ctx).await;
160    }
161
162    not_found_response(&dist_dir, &headers).await
163}
164
165async fn serve_static_asset(
166    path: &str,
167    dist_dir: &std::path::Path,
168    headers: &HeaderMap,
169) -> axum::response::Response {
170    let Ok(files_config) = FilesConfig::get() else {
171        return (
172            StatusCode::INTERNAL_SERVER_ERROR,
173            "FilesConfig not initialized",
174        )
175            .into_response();
176    };
177
178    let files_prefix = format!("{}/", files_config.url_prefix());
179    let asset_path = path.strip_prefix(&files_prefix).map_or_else(
180        || dist_dir.join(path.trim_start_matches('/')),
181        |relative_path| files_config.files().join(relative_path),
182    );
183
184    if asset_path.exists() && asset_path.is_file() {
185        let mime_type = resolve_mime_type(&asset_path);
186        return serve_cached_file(&asset_path, headers, mime_type, CACHE_STATIC_ASSET).await;
187    }
188
189    (StatusCode::NOT_FOUND, "Asset not found").into_response()
190}
191
192async fn serve_metadata_file(
193    path: &str,
194    dist_dir: &std::path::Path,
195    headers: &HeaderMap,
196) -> axum::response::Response {
197    let trimmed_path = path.trim_start_matches('/');
198    let file_path = dist_dir.join(trimmed_path);
199    if !file_path.exists() {
200        return (StatusCode::NOT_FOUND, "File not found").into_response();
201    }
202
203    let mime_type = if path == "/feed.xml" {
204        "application/rss+xml; charset=utf-8"
205    } else {
206        match file_path.extension().and_then(|ext| ext.to_str()) {
207            Some("xml") => "application/xml",
208            _ => "text/plain",
209        }
210    };
211
212    serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
213}
214
215struct ContentPageRequest<'a> {
216    path: &'a str,
217    trimmed_path: &'a str,
218    slug: &'a str,
219    source_id: &'a str,
220    dist_dir: &'a std::path::Path,
221    headers: &'a HeaderMap,
222}
223
224async fn serve_content_page(
225    req: ContentPageRequest<'_>,
226    ctx: &AppContext,
227) -> axum::response::Response {
228    let exact_path = req.dist_dir.join(req.trimmed_path);
229    if exact_path.exists() && exact_path.is_file() {
230        return serve_cached_file(&exact_path, req.headers, "text/html", CACHE_HTML).await;
231    }
232
233    let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
234    if index_path.exists() {
235        return serve_cached_file(&index_path, req.headers, "text/html", CACHE_HTML).await;
236    }
237
238    let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
239        return (
240            StatusCode::INTERNAL_SERVER_ERROR,
241            axum::response::Html("Database connection error"),
242        )
243            .into_response();
244    };
245
246    let source_id = SourceId::new(req.source_id);
247    match content_repo
248        .get_by_source_and_slug(&source_id, req.slug)
249        .await
250    {
251        Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
252        Ok(None) => not_found_response(req.dist_dir, req.headers).await,
253        Err(e) => {
254            tracing::error!(error = %e, "Database error checking content");
255            (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
256        },
257    }
258}
259
260fn not_prerendered_response(path: &str, slug: &str) -> axum::response::Response {
261    (
262        StatusCode::INTERNAL_SERVER_ERROR,
263        axum::response::Html(format!(
264            concat!(
265                "<!DOCTYPE html><html><head><title>Content Not Prerendered</title>",
266                "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
267                "content=\"width=device-width, initial-scale=1\">",
268                "</head><body><h1>Content Not Prerendered</h1>",
269                "<p>Content exists but has not been prerendered to HTML.</p>",
270                "<p>Route: <code>{}</code></p><p>Slug: <code>{}</code></p>",
271                "</body></html>",
272            ),
273            path, slug
274        )),
275    )
276        .into_response()
277}
278
279async fn not_found_response(
280    dist_dir: &std::path::Path,
281    headers: &HeaderMap,
282) -> axum::response::Response {
283    let custom_404 = dist_dir.join("404.html");
284    if custom_404.exists() {
285        if let Ok(content) = tokio::fs::read(&custom_404).await {
286            let etag = compute_etag(&content);
287            if etag_matches(headers, &etag) {
288                return not_modified_response(&etag, CACHE_HTML);
289            }
290            return (
291                StatusCode::NOT_FOUND,
292                [
293                    (header::CONTENT_TYPE, "text/html".to_string()),
294                    (header::CACHE_CONTROL, CACHE_HTML.to_string()),
295                    (header::ETAG, etag),
296                ],
297                content,
298            )
299                .into_response();
300        }
301    }
302
303    (
304        StatusCode::NOT_FOUND,
305        axum::response::Html(concat!(
306            "<!DOCTYPE html><html><head><title>404 Not Found</title>",
307            "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
308            "content=\"width=device-width, initial-scale=1\">",
309            "</head><body><h1>404 - Page Not Found</h1>",
310            "<p>The page you're looking for doesn't exist.</p>",
311            "<p><a href=\"/\">Back to home</a></p></body></html>",
312        )),
313    )
314        .into_response()
315}