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