Skip to main content

systemprompt_api/services/static_content/
static_files.rs

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