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::{resolve_mime_type, 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",
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(&parent_route_path, &headers, "text/html", CACHE_HTML).await;
76 }
77
78 if let Some((slug, source_id)) = state.matcher.matches(path) {
79 let req = ContentPageRequest {
80 path,
81 trimmed_path,
82 slug: &slug,
83 source_id: &source_id,
84 dist_dir: &dist_dir,
85 headers: &headers,
86 };
87 return serve_content_page(req, &state.ctx).await;
88 }
89
90 not_found_response(&dist_dir, &headers).await
91}
92
93async fn serve_static_asset(
94 path: &str,
95 dist_dir: &std::path::Path,
96 headers: &HeaderMap,
97) -> axum::response::Response {
98 let Ok(files_config) = FilesConfig::get() else {
99 return (
100 StatusCode::INTERNAL_SERVER_ERROR,
101 "FilesConfig not initialized",
102 )
103 .into_response();
104 };
105
106 let files_prefix = format!("{}/", files_config.url_prefix());
107 let asset_path = path.strip_prefix(&files_prefix).map_or_else(
108 || dist_dir.join(path.trim_start_matches('/')),
109 |relative_path| files_config.files().join(relative_path),
110 );
111
112 if asset_path.exists() && asset_path.is_file() {
113 let mime_type = resolve_mime_type(&asset_path);
114 return serve_cached_file(&asset_path, headers, mime_type, CACHE_STATIC_ASSET).await;
115 }
116
117 (StatusCode::NOT_FOUND, "Asset not found").into_response()
118}
119
120async fn serve_metadata_file(
121 path: &str,
122 dist_dir: &std::path::Path,
123 headers: &HeaderMap,
124) -> axum::response::Response {
125 let trimmed_path = path.trim_start_matches('/');
126 let file_path = dist_dir.join(trimmed_path);
127 if !file_path.exists() {
128 return (StatusCode::NOT_FOUND, "File not found").into_response();
129 }
130
131 let mime_type = if path == "/feed.xml" {
132 "application/rss+xml; charset=utf-8"
133 } else {
134 match file_path.extension().and_then(|ext| ext.to_str()) {
135 Some("xml") => "application/xml",
136 _ => "text/plain",
137 }
138 };
139
140 serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
141}
142
143struct ContentPageRequest<'a> {
144 path: &'a str,
145 trimmed_path: &'a str,
146 slug: &'a str,
147 source_id: &'a str,
148 dist_dir: &'a std::path::Path,
149 headers: &'a HeaderMap,
150}
151
152async fn serve_content_page(
153 req: ContentPageRequest<'_>,
154 ctx: &AppContext,
155) -> axum::response::Response {
156 let exact_path = req.dist_dir.join(req.trimmed_path);
157 if exact_path.exists() && exact_path.is_file() {
158 return serve_cached_file(&exact_path, req.headers, "text/html", CACHE_HTML).await;
159 }
160
161 let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
162 if index_path.exists() {
163 return serve_cached_file(&index_path, req.headers, "text/html", CACHE_HTML).await;
164 }
165
166 let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
167 return (
168 StatusCode::INTERNAL_SERVER_ERROR,
169 axum::response::Html("Database connection error"),
170 )
171 .into_response();
172 };
173
174 let source_id = SourceId::new(req.source_id);
175 match content_repo
176 .get_by_source_and_slug(&source_id, req.slug, &LocaleCode::new("en"))
177 .await
178 {
179 Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
180 Ok(None) => not_found_response(req.dist_dir, req.headers).await,
181 Err(e) => {
182 tracing::error!(error = %e, "Database error checking content");
183 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
184 },
185 }
186}