Skip to main content

systemprompt_api/services/static_content/static_files/
mod.rs

1//! Static-file request handling with caching and content fallback.
2//!
3//! [`serve_static_content`] routes an incoming URI to a static asset,
4//! prerendered HTML page, metadata file, or the content-repository fallback,
5//! applying the appropriate cache policy and `ETag` for each.
6//! [`StaticContentState`] carries the app context, matcher, and route
7//! classifier into the handler.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12mod 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, StatusCode, Uri};
22use axum::response::IntoResponse;
23use std::sync::Arc;
24
25use super::config::StaticContentMatcher;
26use cache::serve_cached_file;
27use responses::{not_found_response, not_prerendered_response};
28use systemprompt_content::ContentRepository;
29use systemprompt_files::FilesConfig;
30use systemprompt_identifiers::{LocaleCode, SourceId};
31use systemprompt_models::{RouteClassifier, RouteType};
32use systemprompt_runtime::AppContext;
33
34#[derive(Clone, Debug)]
35pub struct StaticContentState {
36    pub ctx: Arc<AppContext>,
37    pub matcher: Arc<StaticContentMatcher>,
38    pub route_classifier: Arc<RouteClassifier>,
39}
40
41pub async fn serve_static_content(
42    State(state): State<StaticContentState>,
43    uri: Uri,
44    headers: HeaderMap,
45    _req_ctx: Option<axum::Extension<systemprompt_models::RequestContext>>,
46) -> impl IntoResponse {
47    let dist_dir = state.ctx.app_paths().web().dist().to_path_buf();
48
49    let path = uri.path();
50
51    if matches!(
52        state.route_classifier.classify(path, "GET"),
53        RouteType::StaticAsset { .. }
54    ) {
55        return serve_static_asset(path, &dist_dir, &headers).await;
56    }
57
58    if path == "/" {
59        return serve_cached_file(
60            &dist_dir.join("index.html"),
61            &headers,
62            "text/html; charset=utf-8",
63            CACHE_HTML,
64        )
65        .await;
66    }
67
68    if matches!(
69        path,
70        "/sitemap.xml" | "/robots.txt" | "/llms.txt" | "/feed.xml"
71    ) {
72        return serve_metadata_file(path, &dist_dir, &headers).await;
73    }
74
75    let trimmed_path = path.trim_start_matches('/');
76    let parent_route_path = dist_dir.join(trimmed_path).join("index.html");
77    if parent_route_path.exists() {
78        return serve_cached_file(
79            &parent_route_path,
80            &headers,
81            "text/html; charset=utf-8",
82            CACHE_HTML,
83        )
84        .await;
85    }
86
87    if let Some((slug, source_id)) = state.matcher.matches(path) {
88        let req = ContentPageRequest {
89            path,
90            trimmed_path,
91            slug: &slug,
92            source_id: &source_id,
93            dist_dir: &dist_dir,
94            headers: &headers,
95        };
96        return serve_content_page(req, &state.ctx).await;
97    }
98
99    not_found_response(&dist_dir, &headers).await
100}
101
102async fn serve_static_asset(
103    path: &str,
104    dist_dir: &std::path::Path,
105    headers: &HeaderMap,
106) -> axum::response::Response {
107    let Ok(files_config) = FilesConfig::get() else {
108        return (
109            StatusCode::INTERNAL_SERVER_ERROR,
110            "FilesConfig not initialized",
111        )
112            .into_response();
113    };
114
115    let files_prefix = format!("{}/", files_config.url_prefix());
116    let stored_file = path.strip_prefix(&files_prefix);
117    let asset_path = stored_file.map_or_else(
118        || dist_dir.join(path.trim_start_matches('/')),
119        |relative_path| files_config.files().join(relative_path),
120    );
121
122    if asset_path.exists() && asset_path.is_file() {
123        let mime_type = systemprompt_models::mime::http_content_type(&asset_path);
124        let cache_control = stored_file
125            .and_then(|_| files_config.cache_control())
126            .unwrap_or_else(|| asset_cache_policy(&asset_path));
127        return serve_cached_file(&asset_path, headers, mime_type, cache_control).await;
128    }
129
130    (StatusCode::NOT_FOUND, "Asset not found").into_response()
131}
132
133async fn serve_metadata_file(
134    path: &str,
135    dist_dir: &std::path::Path,
136    headers: &HeaderMap,
137) -> axum::response::Response {
138    let trimmed_path = path.trim_start_matches('/');
139    let file_path = dist_dir.join(trimmed_path);
140    if !file_path.exists() {
141        return (StatusCode::NOT_FOUND, "File not found").into_response();
142    }
143
144    let mime_type = if path == "/feed.xml" {
145        "application/rss+xml; charset=utf-8"
146    } else {
147        systemprompt_models::mime::http_content_type_opt(&file_path)
148            .unwrap_or("text/plain; charset=utf-8")
149    };
150
151    serve_cached_file(&file_path, headers, mime_type, CACHE_METADATA).await
152}
153
154struct ContentPageRequest<'a> {
155    path: &'a str,
156    trimmed_path: &'a str,
157    slug: &'a str,
158    source_id: &'a str,
159    dist_dir: &'a std::path::Path,
160    headers: &'a HeaderMap,
161}
162
163async fn serve_content_page(
164    req: ContentPageRequest<'_>,
165    ctx: &AppContext,
166) -> axum::response::Response {
167    let exact_path = req.dist_dir.join(req.trimmed_path);
168    if exact_path.exists() && exact_path.is_file() {
169        return serve_cached_file(
170            &exact_path,
171            req.headers,
172            "text/html; charset=utf-8",
173            CACHE_HTML,
174        )
175        .await;
176    }
177
178    let index_path = req.dist_dir.join(req.trimmed_path).join("index.html");
179    if index_path.exists() {
180        return serve_cached_file(
181            &index_path,
182            req.headers,
183            "text/html; charset=utf-8",
184            CACHE_HTML,
185        )
186        .await;
187    }
188
189    let Ok(content_repo) = ContentRepository::new(ctx.db_pool()) else {
190        return (
191            StatusCode::INTERNAL_SERVER_ERROR,
192            axum::response::Html("Database connection error"),
193        )
194            .into_response();
195    };
196
197    let source_id = SourceId::new(req.source_id);
198    match content_repo
199        .get_by_source_and_slug(&source_id, req.slug, &LocaleCode::new("en"))
200        .await
201    {
202        Ok(Some(_)) => not_prerendered_response(req.path, req.slug),
203        Ok(None) => not_found_response(req.dist_dir, req.headers).await,
204        Err(e) => {
205            tracing::error!(error = %e, "Database error checking content");
206            (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
207        },
208    }
209}