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