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_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=31536000, immutable";
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 Ok(files_config) = FilesConfig::get() else {
168 return (
169 StatusCode::INTERNAL_SERVER_ERROR,
170 "FilesConfig not initialized",
171 )
172 .into_response();
173 };
174
175 let files_prefix = format!("{}/", files_config.url_prefix());
176 let asset_path = path.strip_prefix(&files_prefix).map_or_else(
177 || dist_dir.join(path.trim_start_matches('/')),
178 |relative_path| files_config.files().join(relative_path),
179 );
180
181 if asset_path.exists() && asset_path.is_file() {
182 let mime_type = resolve_mime_type(&asset_path);
183 return serve_cached_file(&asset_path, headers, mime_type, CACHE_STATIC_ASSET).await;
184 }
185
186 (StatusCode::NOT_FOUND, "Asset not found").into_response()
187}
188
189async fn serve_metadata_file(
190 path: &str,
191 dist_dir: &std::path::Path,
192 headers: &HeaderMap,
193) -> axum::response::Response {
194 let trimmed_path = path.trim_start_matches('/');
195 let file_path = dist_dir.join(trimmed_path);
196 if !file_path.exists() {
197 return (StatusCode::NOT_FOUND, "File not found").into_response();
198 }
199
200 let mime_type = if path == "/feed.xml" {
201 "application/rss+xml; charset=utf-8"
202 } else {
203 match file_path.extension().and_then(|ext| ext.to_str()) {
204 Some("xml") => "application/xml",
205 _ => "text/plain",
206 }
207 };
208
209 serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
210}
211
212struct ContentPageRequest<'a> {
213 path: &'a str,
214 trimmed_path: &'a str,
215 slug: &'a str,
216 dist_dir: &'a std::path::Path,
217 headers: &'a HeaderMap,
218}
219
220async fn serve_content_page(
221 req: ContentPageRequest<'_>,
222 ctx: &AppContext,
223) -> axum::response::Response {
224 let exact_path = req.dist_dir.join(req.trimmed_path);
225 if exact_path.exists() && exact_path.is_file() {
226 return serve_cached_file(&exact_path, req.headers, "text/html", CACHE_HTML).await;
227 }
228
229 let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
230 if index_path.exists() {
231 return serve_cached_file(&index_path, req.headers, "text/html", CACHE_HTML).await;
232 }
233
234 let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
235 return (
236 StatusCode::INTERNAL_SERVER_ERROR,
237 axum::response::Html("Database connection error"),
238 )
239 .into_response();
240 };
241
242 match content_repo.get_by_slug(req.slug).await {
243 Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
244 Ok(None) => not_found_response(req.dist_dir, req.headers).await,
245 Err(e) => {
246 tracing::error!(error = %e, "Database error checking content");
247 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
248 },
249 }
250}
251
252fn not_prerendered_response(path: &str, slug: &str) -> axum::response::Response {
253 (
254 StatusCode::INTERNAL_SERVER_ERROR,
255 axum::response::Html(format!(
256 concat!(
257 "<!DOCTYPE html><html><head><title>Content Not Prerendered</title>",
258 "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
259 "content=\"width=device-width, initial-scale=1\">",
260 "</head><body><h1>Content Not Prerendered</h1>",
261 "<p>Content exists but has not been prerendered to HTML.</p>",
262 "<p>Route: <code>{}</code></p><p>Slug: <code>{}</code></p>",
263 "</body></html>",
264 ),
265 path, slug
266 )),
267 )
268 .into_response()
269}
270
271async fn not_found_response(
272 dist_dir: &std::path::Path,
273 headers: &HeaderMap,
274) -> axum::response::Response {
275 let custom_404 = dist_dir.join("404.html");
276 if custom_404.exists() {
277 if let Ok(content) = tokio::fs::read(&custom_404).await {
278 let etag = compute_etag(&content);
279 if etag_matches(headers, &etag) {
280 return not_modified_response(&etag, CACHE_HTML);
281 }
282 return (
283 StatusCode::NOT_FOUND,
284 [
285 (header::CONTENT_TYPE, "text/html".to_string()),
286 (header::CACHE_CONTROL, CACHE_HTML.to_string()),
287 (header::ETAG, etag),
288 ],
289 content,
290 )
291 .into_response();
292 }
293 }
294
295 (
296 StatusCode::NOT_FOUND,
297 axum::response::Html(concat!(
298 "<!DOCTYPE html><html><head><title>404 Not Found</title>",
299 "<meta charset=\"utf-8\"><meta name=\"viewport\" ",
300 "content=\"width=device-width, initial-scale=1\">",
301 "</head><body><h1>404 - Page Not Found</h1>",
302 "<p>The page you're looking for doesn't exist.</p>",
303 "<p><a href=\"/\">Back to home</a></p></body></html>",
304 )),
305 )
306 .into_response()
307}