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