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