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::{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 = state.ctx.app_paths().web().dist().to_path_buf();
108
109 let path = uri.path();
110
111 if matches!(
112 state.route_classifier.classify(path, "GET"),
113 RouteType::StaticAsset { .. }
114 ) {
115 return serve_static_asset(path, &dist_dir, &headers).await;
116 }
117
118 if path == "/" {
119 return serve_cached_file(
120 &dist_dir.join("index.html"),
121 &headers,
122 "text/html",
123 CACHE_HTML,
124 )
125 .await;
126 }
127
128 if matches!(
129 path,
130 "/sitemap.xml" | "/robots.txt" | "/llms.txt" | "/feed.xml"
131 ) {
132 return serve_metadata_file(path, &dist_dir, &headers).await;
133 }
134
135 let trimmed_path = path.trim_start_matches('/');
136 let parent_route_path = dist_dir.join(trimmed_path).join("index.html");
137 if parent_route_path.exists() {
138 return serve_cached_file(&parent_route_path, &headers, "text/html", CACHE_HTML).await;
139 }
140
141 if let Some((slug, source_id)) = state.matcher.matches(path) {
142 let req = ContentPageRequest {
143 path,
144 trimmed_path,
145 slug: &slug,
146 source_id: &source_id,
147 dist_dir: &dist_dir,
148 headers: &headers,
149 };
150 return serve_content_page(req, &state.ctx).await;
151 }
152
153 not_found_response(&dist_dir, &headers).await
154}
155
156async fn serve_static_asset(
157 path: &str,
158 dist_dir: &std::path::Path,
159 headers: &HeaderMap,
160) -> axum::response::Response {
161 let Ok(files_config) = FilesConfig::get() else {
162 return (
163 StatusCode::INTERNAL_SERVER_ERROR,
164 "FilesConfig not initialized",
165 )
166 .into_response();
167 };
168
169 let files_prefix = format!("{}/", files_config.url_prefix());
170 let asset_path = path.strip_prefix(&files_prefix).map_or_else(
171 || dist_dir.join(path.trim_start_matches('/')),
172 |relative_path| files_config.files().join(relative_path),
173 );
174
175 if asset_path.exists() && asset_path.is_file() {
176 let mime_type = resolve_mime_type(&asset_path);
177 return serve_cached_file(&asset_path, headers, mime_type, CACHE_STATIC_ASSET).await;
178 }
179
180 (StatusCode::NOT_FOUND, "Asset not found").into_response()
181}
182
183async fn serve_metadata_file(
184 path: &str,
185 dist_dir: &std::path::Path,
186 headers: &HeaderMap,
187) -> axum::response::Response {
188 let trimmed_path = path.trim_start_matches('/');
189 let file_path = dist_dir.join(trimmed_path);
190 if !file_path.exists() {
191 return (StatusCode::NOT_FOUND, "File not found").into_response();
192 }
193
194 let mime_type = if path == "/feed.xml" {
195 "application/rss+xml; charset=utf-8"
196 } else {
197 match file_path.extension().and_then(|ext| ext.to_str()) {
198 Some("xml") => "application/xml",
199 _ => "text/plain",
200 }
201 };
202
203 serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
204}
205
206struct ContentPageRequest<'a> {
207 path: &'a str,
208 trimmed_path: &'a str,
209 slug: &'a str,
210 source_id: &'a str,
211 dist_dir: &'a std::path::Path,
212 headers: &'a HeaderMap,
213}
214
215async fn serve_content_page(
216 req: ContentPageRequest<'_>,
217 ctx: &AppContext,
218) -> axum::response::Response {
219 let exact_path = req.dist_dir.join(req.trimmed_path);
220 if exact_path.exists() && exact_path.is_file() {
221 return serve_cached_file(&exact_path, req.headers, "text/html", CACHE_HTML).await;
222 }
223
224 let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
225 if index_path.exists() {
226 return serve_cached_file(&index_path, req.headers, "text/html", CACHE_HTML).await;
227 }
228
229 let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
230 return (
231 StatusCode::INTERNAL_SERVER_ERROR,
232 axum::response::Html("Database connection error"),
233 )
234 .into_response();
235 };
236
237 let source_id = SourceId::new(req.source_id);
238 match content_repo
239 .get_by_source_and_slug(&source_id, req.slug)
240 .await
241 {
242 Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
243 Ok(None) => not_found_response(req.dist_dir, req.headers).await,
244 Err(e) => {
245 tracing::error!(error = %e, "Database error checking content");
246 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
247 },
248 }
249}
250
251fn not_prerendered_response(path: &str, slug: &str) -> axum::response::Response {
252 (
253 StatusCode::INTERNAL_SERVER_ERROR,
254 axum::response::Html(format!(
255 concat!(
256 "<!DOCTYPE html><html><head><title>Content Not Prerendered</title>",
257 "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
258 "content=\"width=device-width, initial-scale=1\">",
259 "</head><body><h1>Content Not Prerendered</h1>",
260 "<p>Content exists but has not been prerendered to HTML.</p>",
261 "<p>Route: <code>{}</code></p><p>Slug: <code>{}</code></p>",
262 "</body></html>",
263 ),
264 path, slug
265 )),
266 )
267 .into_response()
268}
269
270async fn not_found_response(
271 dist_dir: &std::path::Path,
272 headers: &HeaderMap,
273) -> axum::response::Response {
274 let custom_404 = dist_dir.join("404.html");
275 if custom_404.exists() {
276 if let Ok(content) = tokio::fs::read(&custom_404).await {
277 let etag = compute_etag(&content);
278 if etag_matches(headers, &etag) {
279 return not_modified_response(&etag, CACHE_HTML);
280 }
281 return (
282 StatusCode::NOT_FOUND,
283 [
284 (header::CONTENT_TYPE, "text/html".to_string()),
285 (header::CACHE_CONTROL, CACHE_HTML.to_string()),
286 (header::ETAG, etag),
287 ],
288 content,
289 )
290 .into_response();
291 }
292 }
293
294 (
295 StatusCode::NOT_FOUND,
296 axum::response::Html(concat!(
297 "<!DOCTYPE html><html><head><title>404 Not Found</title>",
298 "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
299 "content=\"width=device-width, initial-scale=1\">",
300 "</head><body><h1>404 - Page Not Found</h1>",
301 "<p>The page you're looking for doesn't exist.</p>",
302 "<p><a href=\"/\">Back to home</a></p></body></html>",
303 )),
304 )
305 .into_response()
306}