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