char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsimplify + split www daemon3665650

main
5.2 KiB176 linesraw
1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::Result;
5use axum::body::Body;
6use axum::extract::{OriginalUri, Path as AxumPath, State};
7use axum::http::{StatusCode, header};
8use axum::response::{IntoResponse, Redirect, Response};
9use include_dir::{Dir, include_dir};
10
11use crate::generate::{MANIFEST_FILE, STATE_FILE, STYLESHEET};
12use crate::render::encode_path;
13
14use super::{AppState, WebResult, internal, x_accel};
15
16/// Bundle first: `cd web && deno task build`.
17static WEB_DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/dist");
18static FONTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets/fonts");
19
20pub(super) fn stage_assets(cache: &Path) -> Result<()> {
21    // Embedded assets are served via X-Accel like everything else, so nginx
22    // provides ETag/Last-Modified/304s. Write only on change to keep the
23    // mtime-derived ETags stable across restarts.
24    write_asset(&cache.join("css/style.css"), STYLESHEET.as_bytes())?;
25    write_embedded_dir(&WEB_DIST, &cache.join("js"))?;
26    write_embedded_dir(&FONTS, &cache.join("fonts"))
27}
28
29fn write_embedded_dir(dir: &Dir<'_>, out: &Path) -> Result<()> {
30    for file in dir.files() {
31        write_asset(&out.join(file.path()), file.contents())?;
32    }
33    for child in dir.dirs() {
34        write_embedded_dir(child, out)?;
35    }
36    Ok(())
37}
38
39fn write_asset(path: &Path, content: &[u8]) -> Result<()> {
40    if fs::read(path).ok().as_deref() != Some(content) {
41        fs::create_dir_all(path.parent().expect("asset path has a parent"))?;
42        fs::write(path, content)?;
43    }
44    Ok(())
45}
46
47pub(super) async fn index(State(state): State<AppState>) -> Response {
48    let html = state.snapshot.load().catalog_html.clone();
49    (
50        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
51        Body::from(html),
52    )
53        .into_response()
54}
55
56pub(super) async fn stylesheet(State(state): State<AppState>) -> WebResult<Response> {
57    serve_cache_path(&state, "css/style.css")
58}
59
60pub(super) async fn js_asset(
61    State(state): State<AppState>,
62    AxumPath(path): AxumPath<String>,
63) -> WebResult<Response> {
64    embedded_asset(&state, &WEB_DIST, "js", &path)
65}
66
67pub(super) async fn font_asset(
68    State(state): State<AppState>,
69    AxumPath(path): AxumPath<String>,
70) -> WebResult<Response> {
71    embedded_asset(&state, &FONTS, "fonts", &path)
72}
73
74fn embedded_asset(
75    state: &AppState,
76    dir: &Dir<'_>,
77    prefix: &str,
78    path: &str,
79) -> WebResult<Response> {
80    if dir.get_file(path).is_none() {
81        return Err((StatusCode::NOT_FOUND, "not found".into()));
82    }
83    serve_cache_path(state, &format!("{prefix}/{}", encode_path(path)))
84}
85
86pub(super) async fn add_trailing_slash(OriginalUri(uri): OriginalUri) -> Redirect {
87    Redirect::permanent(&format!("{}/", uri.path()))
88}
89
90pub(super) async fn repo_root(
91    State(state): State<AppState>,
92    OriginalUri(uri): OriginalUri,
93    AxumPath((user, repo)): AxumPath<(String, String)>,
94) -> WebResult<Response> {
95    serve_repo(state, user, repo, String::new(), uri.path().to_owned()).await
96}
97
98pub(super) async fn repo_path(
99    State(state): State<AppState>,
100    OriginalUri(uri): OriginalUri,
101    AxumPath((user, repo, path)): AxumPath<(String, String, String)>,
102) -> WebResult<Response> {
103    serve_repo(state, user, repo, path, uri.path().to_owned()).await
104}
105
106async fn serve_repo(
107    state: AppState,
108    user: String,
109    name: String,
110    path: String,
111    request_path: String,
112) -> WebResult<Response> {
113    let entry = state.resolve(&user, &name)?;
114    let relative = requested_file(&path)?;
115    let output = state.config.repo_cache(&entry.repository);
116    if !output.join(STATE_FILE).is_file() {
117        state.build(&entry).await.map_err(internal)?;
118    }
119
120    let target = output.join(&relative);
121    if target.is_dir() {
122        return Ok(Redirect::permanent(&format!("{request_path}/")).into_response());
123    }
124    if !target.is_file() {
125        return Err((StatusCode::NOT_FOUND, "page not found".into()));
126    }
127
128    serve_cache_path(
129        &state,
130        &format!(
131            "{}/{}/{}",
132            encode_path(&entry.repository.user),
133            encode_path(&entry.repository.name),
134            encode_path(&relative.to_string_lossy()),
135        ),
136    )
137}
138
139fn serve_cache_path(state: &AppState, encoded_path: &str) -> WebResult<Response> {
140    x_accel(&state.config.internal_prefix, encoded_path)
141}
142
143fn requested_file(path: &str) -> WebResult<PathBuf> {
144    if path.is_empty() {
145        return Ok(PathBuf::from("index.html"));
146    }
147    if path == STATE_FILE || path == MANIFEST_FILE {
148        return Err((StatusCode::NOT_FOUND, "page not found".into()));
149    }
150    let directory = path.ends_with('/');
151    let path = Path::new(path);
152    if path
153        .components()
154        .any(|component| !matches!(component, Component::Normal(_)))
155    {
156        return Err((StatusCode::BAD_REQUEST, "invalid path".into()));
157    }
158    Ok(if directory {
159        path.join("index.html")
160    } else {
161        path.to_owned()
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use crate::generate::{MANIFEST_FILE, STATE_FILE};
168
169    use super::requested_file;
170
171    #[test]
172    fn generator_metadata_is_private() {
173        assert!(requested_file(STATE_FILE).is_err());
174        assert!(requested_file(MANIFEST_FILE).is_err());
175    }
176}