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