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