systemprompt_api/services/static_content/static_files/
mod.rs1mod cache;
13mod responses;
14
15pub use cache::{CACHE_HTML, CACHE_METADATA, CACHE_STATIC_ASSET, compute_etag};
16
17use axum::extract::State;
18use axum::http::{HeaderMap, StatusCode, Uri};
19use axum::response::IntoResponse;
20use std::sync::Arc;
21
22use super::config::StaticContentMatcher;
23use cache::serve_cached_file;
24use responses::{not_found_response, not_prerendered_response};
25use systemprompt_content::ContentRepository;
26use systemprompt_files::FilesConfig;
27use systemprompt_identifiers::{LocaleCode, SourceId};
28use systemprompt_models::{RouteClassifier, RouteType};
29use systemprompt_runtime::AppContext;
30
31#[derive(Clone, Debug)]
32pub struct StaticContentState {
33 pub ctx: Arc<AppContext>,
34 pub matcher: Arc<StaticContentMatcher>,
35 pub route_classifier: Arc<RouteClassifier>,
36}
37
38pub async fn serve_static_content(
39 State(state): State<StaticContentState>,
40 uri: Uri,
41 headers: HeaderMap,
42 _req_ctx: Option<axum::Extension<systemprompt_models::RequestContext>>,
43) -> impl IntoResponse {
44 let dist_dir = state.ctx.app_paths().web().dist().to_path_buf();
45
46 let path = uri.path();
47
48 if matches!(
49 state.route_classifier.classify(path, "GET"),
50 RouteType::StaticAsset { .. }
51 ) {
52 return serve_static_asset(path, &dist_dir, &headers).await;
53 }
54
55 if path == "/" {
56 return serve_cached_file(
57 &dist_dir.join("index.html"),
58 &headers,
59 "text/html; charset=utf-8",
60 CACHE_HTML,
61 )
62 .await;
63 }
64
65 if matches!(
66 path,
67 "/sitemap.xml" | "/robots.txt" | "/llms.txt" | "/feed.xml"
68 ) {
69 return serve_metadata_file(path, &dist_dir, &headers).await;
70 }
71
72 let trimmed_path = path.trim_start_matches('/');
73 let parent_route_path = dist_dir.join(trimmed_path).join("index.html");
74 if parent_route_path.exists() {
75 return serve_cached_file(
76 &parent_route_path,
77 &headers,
78 "text/html; charset=utf-8",
79 CACHE_HTML,
80 )
81 .await;
82 }
83
84 if let Some((slug, source_id)) = state.matcher.matches(path) {
85 let req = ContentPageRequest {
86 path,
87 trimmed_path,
88 slug: &slug,
89 source_id: &source_id,
90 dist_dir: &dist_dir,
91 headers: &headers,
92 };
93 return serve_content_page(req, &state.ctx).await;
94 }
95
96 not_found_response(&dist_dir, &headers).await
97}
98
99async fn serve_static_asset(
100 path: &str,
101 dist_dir: &std::path::Path,
102 headers: &HeaderMap,
103) -> axum::response::Response {
104 let Ok(files_config) = FilesConfig::get() else {
105 return (
106 StatusCode::INTERNAL_SERVER_ERROR,
107 "FilesConfig not initialized",
108 )
109 .into_response();
110 };
111
112 let files_prefix = format!("{}/", files_config.url_prefix());
113 let asset_path = path.strip_prefix(&files_prefix).map_or_else(
114 || dist_dir.join(path.trim_start_matches('/')),
115 |relative_path| files_config.files().join(relative_path),
116 );
117
118 if asset_path.exists() && asset_path.is_file() {
119 let mime_type = systemprompt_models::mime::http_content_type(&asset_path);
120 return serve_cached_file(&asset_path, headers, mime_type, CACHE_STATIC_ASSET).await;
121 }
122
123 (StatusCode::NOT_FOUND, "Asset not found").into_response()
124}
125
126async fn serve_metadata_file(
127 path: &str,
128 dist_dir: &std::path::Path,
129 headers: &HeaderMap,
130) -> axum::response::Response {
131 let trimmed_path = path.trim_start_matches('/');
132 let file_path = dist_dir.join(trimmed_path);
133 if !file_path.exists() {
134 return (StatusCode::NOT_FOUND, "File not found").into_response();
135 }
136
137 let mime_type = if path == "/feed.xml" {
138 "application/rss+xml; charset=utf-8"
139 } else {
140 systemprompt_models::mime::http_content_type_opt(&file_path)
141 .unwrap_or("text/plain; charset=utf-8")
142 };
143
144 serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
145}
146
147struct ContentPageRequest<'a> {
148 path: &'a str,
149 trimmed_path: &'a str,
150 slug: &'a str,
151 source_id: &'a str,
152 dist_dir: &'a std::path::Path,
153 headers: &'a HeaderMap,
154}
155
156async fn serve_content_page(
157 req: ContentPageRequest<'_>,
158 ctx: &AppContext,
159) -> axum::response::Response {
160 let exact_path = req.dist_dir.join(req.trimmed_path);
161 if exact_path.exists() && exact_path.is_file() {
162 return serve_cached_file(
163 &exact_path,
164 req.headers,
165 "text/html; charset=utf-8",
166 CACHE_HTML,
167 )
168 .await;
169 }
170
171 let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
172 if index_path.exists() {
173 return serve_cached_file(
174 &index_path,
175 req.headers,
176 "text/html; charset=utf-8",
177 CACHE_HTML,
178 )
179 .await;
180 }
181
182 let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
183 return (
184 StatusCode::INTERNAL_SERVER_ERROR,
185 axum::response::Html("Database connection error"),
186 )
187 .into_response();
188 };
189
190 let source_id = SourceId::new(req.source_id);
191 match content_repo
192 .get_by_source_and_slug(&source_id, req.slug, &LocaleCode::new("en"))
193 .await
194 {
195 Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
196 Ok(None) => not_found_response(req.dist_dir, req.headers).await,
197 Err(e) => {
198 tracing::error!(error = %e, "Database error checking content");
199 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
200 },
201 }
202}