forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somperf: prioritize push-triggered builds over background scans2688dec

typst_support
6.0 KiB201 linesraw
1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::Result;
5use axum::body::{Body, Bytes};
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    html_response(state.snapshot.load().catalog_html.clone())
49}
50
51pub(super) async fn user_root(
52    State(state): State<AppState>,
53    AxumPath(user): AxumPath<String>,
54) -> WebResult<Response> {
55    let html = state.snapshot.load().user_html.get(&user).cloned();
56    html.map(html_response)
57        .ok_or_else(|| (StatusCode::NOT_FOUND, "not found".into()))
58}
59
60/// Unlike repos, only redirect known users: this route also catches
61/// top-level strays like `/favicon.ico`.
62pub(super) async fn user_redirect(
63    State(state): State<AppState>,
64    OriginalUri(uri): OriginalUri,
65    AxumPath(user): AxumPath<String>,
66) -> WebResult<Redirect> {
67    if !state.snapshot.load().user_html.contains_key(&user) {
68        return Err((StatusCode::NOT_FOUND, "not found".into()));
69    }
70    Ok(add_trailing_slash(OriginalUri(uri)).await)
71}
72
73fn html_response(html: Bytes) -> Response {
74    (
75        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
76        Body::from(html),
77    )
78        .into_response()
79}
80
81pub(super) async fn stylesheet(State(state): State<AppState>) -> WebResult<Response> {
82    serve_cache_path(&state, "css/style.css")
83}
84
85pub(super) async fn js_asset(
86    State(state): State<AppState>,
87    AxumPath(path): AxumPath<String>,
88) -> WebResult<Response> {
89    embedded_asset(&state, &WEB_DIST, "js", &path)
90}
91
92pub(super) async fn font_asset(
93    State(state): State<AppState>,
94    AxumPath(path): AxumPath<String>,
95) -> WebResult<Response> {
96    embedded_asset(&state, &FONTS, "fonts", &path)
97}
98
99fn embedded_asset(
100    state: &AppState,
101    dir: &Dir<'_>,
102    prefix: &str,
103    path: &str,
104) -> WebResult<Response> {
105    if dir.get_file(path).is_none() {
106        return Err((StatusCode::NOT_FOUND, "not found".into()));
107    }
108    serve_cache_path(state, &format!("{prefix}/{}", encode_path(path)))
109}
110
111pub(super) async fn add_trailing_slash(OriginalUri(uri): OriginalUri) -> Redirect {
112    Redirect::permanent(&format!("{}/", uri.path()))
113}
114
115pub(super) async fn repo_root(
116    State(state): State<AppState>,
117    OriginalUri(uri): OriginalUri,
118    AxumPath((user, repo)): AxumPath<(String, String)>,
119) -> WebResult<Response> {
120    serve_repo(state, user, repo, String::new(), uri.path().to_owned()).await
121}
122
123pub(super) async fn repo_path(
124    State(state): State<AppState>,
125    OriginalUri(uri): OriginalUri,
126    AxumPath((user, repo, path)): AxumPath<(String, String, String)>,
127) -> WebResult<Response> {
128    serve_repo(state, user, repo, path, uri.path().to_owned()).await
129}
130
131async fn serve_repo(
132    state: AppState,
133    user: String,
134    name: String,
135    path: String,
136    request_path: String,
137) -> WebResult<Response> {
138    let entry = state.resolve(&user, &name)?;
139    let relative = requested_file(&path)?;
140    let output = state.config.repo_cache(&entry.repository);
141    if !output.join(STATE_FILE).is_file() {
142        state.build(&entry, true).await.map_err(internal)?;
143    }
144
145    let target = output.join(&relative);
146    if target.is_dir() {
147        return Ok(Redirect::permanent(&format!("{request_path}/")).into_response());
148    }
149    if !target.is_file() {
150        return Err((StatusCode::NOT_FOUND, "page not found".into()));
151    }
152
153    serve_cache_path(
154        &state,
155        &format!(
156            "{}/{}/{}",
157            encode_path(&entry.repository.user),
158            encode_path(&entry.repository.name),
159            encode_path(&relative.to_string_lossy()),
160        ),
161    )
162}
163
164fn serve_cache_path(state: &AppState, encoded_path: &str) -> WebResult<Response> {
165    x_accel(&state.config.internal_prefix, encoded_path)
166}
167
168fn requested_file(path: &str) -> WebResult<PathBuf> {
169    if path.is_empty() {
170        return Ok(PathBuf::from("index.html"));
171    }
172    if path == STATE_FILE || path == MANIFEST_FILE {
173        return Err((StatusCode::NOT_FOUND, "page not found".into()));
174    }
175    let directory = path.ends_with('/');
176    let path = Path::new(path);
177    if path
178        .components()
179        .any(|component| !matches!(component, Component::Normal(_)))
180    {
181        return Err((StatusCode::BAD_REQUEST, "invalid path".into()));
182    }
183    Ok(if directory {
184        path.join("index.html")
185    } else {
186        path.to_owned()
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use crate::generate::{MANIFEST_FILE, STATE_FILE};
193
194    use super::requested_file;
195
196    #[test]
197    fn generator_metadata_is_private() {
198        assert!(requested_file(STATE_FILE).is_err());
199        assert!(requested_file(MANIFEST_FILE).is_err());
200    }
201}