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