forks/sorcery

Mirror of https://git.t4t.associates/char/sorcery

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

Charlotte Somdaemon: write catalog pages to disk743f502

grace-patches
7.6 KiB243 linesraw
1use std::collections::HashSet;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result};
6use axum::extract::{OriginalUri, Path as AxumPath, State};
7use axum::http::{HeaderValue, StatusCode, header};
8use axum::response::{IntoResponse, Redirect, Response};
9use include_dir::{Dir, include_dir};
10
11use crate::catalog::Repository;
12use crate::generate::{MANIFEST_FILE, STATE_FILE, STYLESHEET};
13use crate::render::{catalog_index, encode_path, user_index};
14
15use super::{AppState, WebResult, x_accel};
16
17/// Bundle first: `cd web && deno task build`.
18static WEB_DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/dist");
19static FONTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets/fonts");
20
21/// Dot-prefixed so that discovery can never yield a colliding user or repo.
22const CATALOG_DIR: &str = ".catalog";
23
24pub(super) fn stage_assets(cache: &Path) -> Result<()> {
25    // Embedded assets are served via X-Accel like everything else, so nginx
26    // provides ETag/Last-Modified/304s.
27    write_asset(&cache.join("css/style.css"), STYLESHEET.as_bytes())?;
28    write_embedded_dir(&WEB_DIST, &cache.join("js"))?;
29    write_embedded_dir(&FONTS, &cache.join("fonts"))
30}
31
32fn write_embedded_dir(dir: &Dir<'_>, out: &Path) -> Result<()> {
33    for file in dir.files() {
34        write_asset(&out.join(file.path()), file.contents())?;
35    }
36    for child in dir.dirs() {
37        write_embedded_dir(child, out)?;
38    }
39    Ok(())
40}
41
42/// Rewrites the catalog index and per-user pages, removing pages for users
43/// who no longer have any repositories.
44pub(super) fn write_catalog(
45    cache: &Path,
46    instance_name: &str,
47    repositories: &[Repository],
48) -> Result<()> {
49    let catalog = cache.join(CATALOG_DIR);
50    write_asset(
51        &catalog.join("index.html"),
52        catalog_index(instance_name, repositories).as_bytes(),
53    )?;
54    let users = catalog.join("users");
55    let mut live = HashSet::new();
56    for repos in repositories.chunk_by(|a, b| a.user == b.user) {
57        let file = format!("{}.html", repos[0].user);
58        write_asset(
59            &users.join(&file),
60            user_index(instance_name, &repos[0].user, repos).as_bytes(),
61        )?;
62        live.insert(file);
63    }
64    for entry in fs::read_dir(&users).into_iter().flatten() {
65        let entry = entry?;
66        if !live.contains(entry.file_name().to_string_lossy().as_ref()) {
67            fs::remove_file(entry.path())?;
68        }
69    }
70    Ok(())
71}
72
73fn write_asset(path: &Path, content: &[u8]) -> Result<()> {
74    if fs::read(path).ok().as_deref() == Some(content) {
75        // bail if the file is unchanged so that we don't bump mtime
76        // (which would upset mtime-derived ETags)
77        return Ok(());
78    }
79    let parent = path.parent().expect("asset path has a parent");
80    fs::create_dir_all(parent)?;
81    let name = path.file_name().expect("asset path has a name");
82    // write to staging and then rename over so nginx never caches a partially-written file
83    let staging = parent.join(format!(".{}.new", name.to_string_lossy()));
84    fs::write(&staging, content)?;
85    fs::rename(&staging, path).with_context(|| format!("installing {}", path.display()))
86}
87
88pub(super) async fn index(State(state): State<AppState>) -> WebResult<Response> {
89    serve_cache_path(&state, &format!("{CATALOG_DIR}/index.html"))
90}
91
92pub(super) async fn user_root(
93    State(state): State<AppState>,
94    AxumPath(user): AxumPath<String>,
95) -> WebResult<Response> {
96    if !state.snapshot.load().users.contains(&user) {
97        return Err((StatusCode::NOT_FOUND, "not found".into()));
98    }
99    serve_cache_path(
100        &state,
101        &format!("{CATALOG_DIR}/users/{}.html", encode_path(&user)),
102    )
103}
104
105/// Unlike repos, only redirect known users: this route also catches
106/// top-level strays like `/favicon.ico`.
107pub(super) async fn user_redirect(
108    State(state): State<AppState>,
109    OriginalUri(uri): OriginalUri,
110    AxumPath(user): AxumPath<String>,
111) -> WebResult<Redirect> {
112    if !state.snapshot.load().users.contains(&user) {
113        return Err((StatusCode::NOT_FOUND, "not found".into()));
114    }
115    Ok(add_trailing_slash(OriginalUri(uri)).await)
116}
117
118pub(super) async fn stylesheet(State(state): State<AppState>) -> WebResult<Response> {
119    serve_cache_path(&state, "css/style.css")
120}
121
122pub(super) async fn js_asset(
123    State(state): State<AppState>,
124    AxumPath(path): AxumPath<String>,
125) -> WebResult<Response> {
126    embedded_asset(&state, &WEB_DIST, "js", &path)
127}
128
129pub(super) async fn font_asset(
130    State(state): State<AppState>,
131    AxumPath(path): AxumPath<String>,
132) -> WebResult<Response> {
133    embedded_asset(&state, &FONTS, "fonts", &path)
134}
135
136fn embedded_asset(
137    state: &AppState,
138    dir: &Dir<'_>,
139    prefix: &str,
140    path: &str,
141) -> WebResult<Response> {
142    if dir.get_file(path).is_none() {
143        return Err((StatusCode::NOT_FOUND, "not found".into()));
144    }
145    serve_cache_path(state, &format!("{prefix}/{}", encode_path(path)))
146}
147
148pub(super) async fn add_trailing_slash(OriginalUri(uri): OriginalUri) -> Redirect {
149    Redirect::permanent(&format!("{}/", uri.path()))
150}
151
152pub(super) async fn repo_root(
153    State(state): State<AppState>,
154    OriginalUri(uri): OriginalUri,
155    AxumPath((user, repo)): AxumPath<(String, String)>,
156) -> WebResult<Response> {
157    serve_repo(state, user, repo, String::new(), uri.path().to_owned()).await
158}
159
160pub(super) async fn repo_path(
161    State(state): State<AppState>,
162    OriginalUri(uri): OriginalUri,
163    AxumPath((user, repo, path)): AxumPath<(String, String, String)>,
164) -> WebResult<Response> {
165    serve_repo(state, user, repo, path, uri.path().to_owned()).await
166}
167
168async fn serve_repo(
169    state: AppState,
170    user: String,
171    name: String,
172    path: String,
173    request_path: String,
174) -> WebResult<Response> {
175    let entry = state.resolve(&user, &name)?;
176    let relative = requested_file(&path)?;
177    let output = state.config.repo_cache(&entry.repository);
178    if !output.join(STATE_FILE).is_file() {
179        return Ok((
180            StatusCode::SERVICE_UNAVAILABLE,
181            [(header::RETRY_AFTER, HeaderValue::from_static("10"))],
182            "this repository's site is still being generated",
183        )
184            .into_response());
185    }
186
187    let target = output.join(&relative);
188    if target.is_dir() {
189        return Ok(Redirect::permanent(&format!("{request_path}/")).into_response());
190    }
191    if !target.is_file() {
192        return Err((StatusCode::NOT_FOUND, "page not found".into()));
193    }
194
195    serve_cache_path(
196        &state,
197        &format!(
198            "{}/{}/{}",
199            encode_path(&entry.repository.user),
200            encode_path(&entry.repository.name),
201            encode_path(&relative.to_string_lossy()),
202        ),
203    )
204}
205
206fn serve_cache_path(state: &AppState, encoded_path: &str) -> WebResult<Response> {
207    x_accel(&state.config.internal_prefix, encoded_path)
208}
209
210fn requested_file(path: &str) -> WebResult<PathBuf> {
211    if path.is_empty() {
212        return Ok(PathBuf::from("index.html"));
213    }
214    if path == STATE_FILE || path == MANIFEST_FILE {
215        return Err((StatusCode::NOT_FOUND, "page not found".into()));
216    }
217    let directory = path.ends_with('/');
218    let path = Path::new(path);
219    if path
220        .components()
221        .any(|component| !matches!(component, Component::Normal(_)))
222    {
223        return Err((StatusCode::BAD_REQUEST, "invalid path".into()));
224    }
225    Ok(if directory {
226        path.join("index.html")
227    } else {
228        path.to_owned()
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    use crate::generate::{MANIFEST_FILE, STATE_FILE};
235
236    use super::requested_file;
237
238    #[test]
239    fn generator_metadata_is_private() {
240        assert!(requested_file(STATE_FILE).is_err());
241        assert!(requested_file(MANIFEST_FILE).is_err());
242    }
243}