Skip to main content

systemprompt_api/services/static_content/
static_files.rs

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