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