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