systemprompt_api/services/static_content/
vite.rs1use axum::extract::State;
2use axum::http::{header, StatusCode, Uri};
3use axum::response::IntoResponse;
4use std::sync::Arc;
5
6use super::config::StaticContentMatcher;
7use systemprompt_content::ContentRepository;
8use systemprompt_files::FilesConfig;
9use systemprompt_models::{AppPaths, RouteClassifier, RouteType};
10use systemprompt_runtime::AppContext;
11
12#[derive(Clone, Debug)]
13pub struct StaticContentState {
14 pub ctx: Arc<AppContext>,
15 pub matcher: Arc<StaticContentMatcher>,
16 pub route_classifier: Arc<RouteClassifier>,
17}
18
19pub async fn serve_static_content(
20 State(state): State<StaticContentState>,
21 uri: Uri,
22 req_ctx: Option<axum::Extension<systemprompt_models::RequestContext>>,
23) -> impl IntoResponse {
24 let matcher = state.matcher;
25 let dist_dir = match AppPaths::get() {
26 Ok(paths) => paths.web().dist().to_path_buf(),
27 Err(_) => {
28 return (
29 StatusCode::INTERNAL_SERVER_ERROR,
30 "AppPaths not initialized",
31 )
32 .into_response();
33 },
34 };
35
36 let path = uri.path();
37
38 if matches!(
39 state.route_classifier.classify(path, "GET"),
40 RouteType::StaticAsset { .. }
41 ) {
42 let files_config = match FilesConfig::get() {
43 Ok(config) => config,
44 Err(_) => {
45 return (
46 StatusCode::INTERNAL_SERVER_ERROR,
47 "FilesConfig not initialized",
48 )
49 .into_response();
50 },
51 };
52 let files_prefix = format!("{}/", files_config.url_prefix());
53 let asset_path = if let Some(relative_path) = path.strip_prefix(&files_prefix) {
54 files_config.files().join(relative_path)
55 } else {
56 let trimmed_path = path.trim_start_matches('/');
57 dist_dir.join(trimmed_path)
58 };
59
60 if asset_path.exists() && asset_path.is_file() {
61 match std::fs::read(&asset_path) {
62 Ok(content) => {
63 let mime_type = match asset_path.extension().and_then(|ext| ext.to_str()) {
64 Some("js") => "application/javascript",
65 Some("css") => "text/css",
66 Some("woff" | "woff2") => "font/woff2",
67 Some("ttf") => "font/ttf",
68 Some("png") => "image/png",
69 Some("jpg" | "jpeg") => "image/jpeg",
70 Some("svg") => "image/svg+xml",
71 Some("ico") => "image/x-icon",
72 Some("json") => "application/json",
73 _ => "application/octet-stream",
74 };
75
76 return (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], content)
77 .into_response();
78 },
79 Err(_) => {
80 return (StatusCode::INTERNAL_SERVER_ERROR, "Error reading asset")
81 .into_response();
82 },
83 }
84 }
85 return (StatusCode::NOT_FOUND, "Asset not found").into_response();
86 }
87
88 if path == "/" {
89 let index_path = dist_dir.join("index.html");
90 if index_path.exists() {
91 match std::fs::read(&index_path) {
92 Ok(content) => {
93 return (
94 StatusCode::OK,
95 [(header::CONTENT_TYPE, "text/html")],
96 content,
97 )
98 .into_response();
99 },
100 Err(_) => {
101 return (
102 StatusCode::INTERNAL_SERVER_ERROR,
103 "Error reading index.html",
104 )
105 .into_response();
106 },
107 }
108 }
109 return (StatusCode::NOT_FOUND, "Homepage not found").into_response();
110 }
111
112 if path == "/sitemap.xml" || path == "/robots.txt" || path == "/llms.txt" || path == "/feed.xml"
113 {
114 let trimmed_path = path.trim_start_matches('/');
115 let file_path = dist_dir.join(trimmed_path);
116 if file_path.exists() {
117 match std::fs::read(&file_path) {
118 Ok(content) => {
119 let mime_type = if path == "/feed.xml" {
120 "application/rss+xml; charset=utf-8"
121 } else {
122 match file_path.extension().and_then(|ext| ext.to_str()) {
123 Some("xml") => "application/xml",
124 _ => "text/plain",
125 }
126 };
127 return (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], content)
128 .into_response();
129 },
130 Err(_) => {
131 return (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file")
132 .into_response();
133 },
134 }
135 }
136 return (StatusCode::NOT_FOUND, "File not found").into_response();
137 }
138
139 let trimmed_path = path.trim_start_matches('/');
140 let parent_route_path = dist_dir.join(trimmed_path).join("index.html");
141 if parent_route_path.exists() {
142 match std::fs::read(&parent_route_path) {
143 Ok(content) => {
144 return (
145 StatusCode::OK,
146 [(header::CONTENT_TYPE, "text/html")],
147 content,
148 )
149 .into_response();
150 },
151 Err(_) => {
152 return (
153 StatusCode::INTERNAL_SERVER_ERROR,
154 "Error reading parent route",
155 )
156 .into_response();
157 },
158 }
159 }
160
161 if let Some((slug, source_id)) = matcher.matches(path) {
162 let exact_path = dist_dir.join(trimmed_path);
163 if exact_path.exists() && exact_path.is_file() {
164 return serve_html_with_analytics(
165 &exact_path,
166 &slug,
167 &source_id,
168 req_ctx.as_ref().map(|ext| ext.0.clone()),
169 )
170 .into_response();
171 }
172
173 let index_path = dist_dir.join(trimmed_path).join("index.html");
174 if index_path.exists() {
175 return serve_html_with_analytics(
176 &index_path,
177 &slug,
178 &source_id,
179 req_ctx.as_ref().map(|ext| ext.0.clone()),
180 )
181 .into_response();
182 }
183
184 let content_repo = match ContentRepository::new(state.ctx.db_pool()) {
185 Ok(r) => r,
186 Err(_) => {
187 return (
188 StatusCode::INTERNAL_SERVER_ERROR,
189 axum::response::Html("Database connection error"),
190 )
191 .into_response();
192 },
193 };
194 match content_repo.get_by_slug(&slug).await {
195 Ok(Some(_)) => {
196 return (
197 StatusCode::INTERNAL_SERVER_ERROR,
198 axum::response::Html(format!(
199 r#"<!DOCTYPE html>
200<html>
201<head>
202 <title>Content Not Prerendered</title>
203 <meta charset="utf-8">
204 <meta name="viewport" content="width=device-width, initial-scale=1">
205 <style>
206 body {{ font-family: system-ui, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }}
207 h1 {{ color: #d32f2f; }}
208 code {{ background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }}
209 </style>
210</head>
211<body>
212 <h1>Content Not Prerendered</h1>
213 <p>The content exists in the database but has not been prerendered to HTML.</p>
214 <p>Route: <code>{}</code></p>
215 <p>Slug: <code>{}</code></p>
216 <p>Run the prerendering build step to generate static HTML.</p>
217</body>
218</html>"#,
219 path, slug
220 ))
221 ).into_response();
222 },
223 Ok(None) => {
224 return (
225 StatusCode::NOT_FOUND,
226 axum::response::Html(r#"<!DOCTYPE html>
227<html>
228<head>
229 <title>404 Not Found</title>
230 <meta charset="utf-8">
231 <meta name="viewport" content="width=device-width, initial-scale=1">
232 <style>
233 body { font-family: system-ui, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }
234 h1 { color: #333; }
235 a { color: #1976d2; text-decoration: none; }
236 a:hover { text-decoration: underline; }
237 </style>
238</head>
239<body>
240 <h1>404 - Page Not Found</h1>
241 <p>The page you're looking for doesn't exist.</p>
242 <p><a href="/">← Back to home</a></p>
243</body>
244</html>"#.to_string())
245 ).into_response();
246 },
247 Err(e) => {
248 tracing::error!(error = %e, "Database error checking content");
249 return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
250 .into_response();
251 },
252 }
253 }
254
255 (
256 StatusCode::NOT_FOUND,
257 axum::response::Html(r#"<!DOCTYPE html>
258<html>
259<head>
260 <title>404 Not Found</title>
261 <meta charset="utf-8">
262 <meta name="viewport" content="width=device-width, initial-scale=1">
263 <style>
264 body { font-family: system-ui, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }
265 h1 { color: #333; }
266 a { color: #1976d2; text-decoration: none; }
267 a:hover { text-decoration: underline; }
268 </style>
269</head>
270<body>
271 <h1>404 - Page Not Found</h1>
272 <p>The page you're looking for doesn't exist.</p>
273 <p><a href="/">← Back to home</a></p>
274</body>
275</html>"#)
276 ).into_response()
277}
278
279fn serve_html_with_analytics(
280 html_path: &std::path::Path,
281 _slug: &str,
282 _source_id: &str,
283 _req_ctx: Option<systemprompt_models::RequestContext>,
284) -> impl IntoResponse {
285 let Ok(html_content) = std::fs::read(html_path) else {
286 return (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response();
287 };
288
289 let mut response = (StatusCode::OK, html_content).into_response();
290 response.headers_mut().insert(
291 header::CONTENT_TYPE,
292 http::HeaderValue::from_static("text/html"),
293 );
294
295 response
296}