use std::collections::HashSet; use std::fs; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result}; use axum::extract::{OriginalUri, Path as AxumPath, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use include_dir::{Dir, include_dir}; use crate::catalog::Repository; use crate::generate::{MANIFEST_FILE, STATE_FILE, STYLESHEET}; use crate::render::{catalog_index, encode_path, user_index}; use super::{AppState, WebResult, x_accel}; /// Bundle first: `cd web && deno task build`. static WEB_DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/dist"); static FONTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets/fonts"); /// Dot-prefixed so that discovery can never yield a colliding user or repo. const CATALOG_DIR: &str = ".catalog"; pub(super) fn stage_assets(cache: &Path) -> Result<()> { // Embedded assets are served via X-Accel like everything else, so nginx // provides ETag/Last-Modified/304s. write_asset(&cache.join("css/style.css"), STYLESHEET.as_bytes())?; write_embedded_dir(&WEB_DIST, &cache.join("js"))?; write_embedded_dir(&FONTS, &cache.join("fonts")) } fn write_embedded_dir(dir: &Dir<'_>, out: &Path) -> Result<()> { for file in dir.files() { write_asset(&out.join(file.path()), file.contents())?; } for child in dir.dirs() { write_embedded_dir(child, out)?; } Ok(()) } /// Rewrites the catalog index and per-user pages, removing pages for users /// who no longer have any repositories. pub(super) fn write_catalog( cache: &Path, instance_name: &str, repositories: &[Repository], ) -> Result<()> { let catalog = cache.join(CATALOG_DIR); write_asset( &catalog.join("index.html"), catalog_index(instance_name, repositories).as_bytes(), )?; let users = catalog.join("users"); let mut live = HashSet::new(); for repos in repositories.chunk_by(|a, b| a.user == b.user) { let file = format!("{}.html", repos[0].user); write_asset( &users.join(&file), user_index(instance_name, &repos[0].user, repos).as_bytes(), )?; live.insert(file); } for entry in fs::read_dir(&users).into_iter().flatten() { let entry = entry?; if !live.contains(entry.file_name().to_string_lossy().as_ref()) { fs::remove_file(entry.path())?; } } Ok(()) } fn write_asset(path: &Path, content: &[u8]) -> Result<()> { if fs::read(path).ok().as_deref() == Some(content) { // bail if the file is unchanged so that we don't bump mtime // (which would upset mtime-derived ETags) return Ok(()); } let parent = path.parent().expect("asset path has a parent"); fs::create_dir_all(parent)?; let name = path.file_name().expect("asset path has a name"); // write to staging and then rename over so nginx never caches a partially-written file let staging = parent.join(format!(".{}.new", name.to_string_lossy())); fs::write(&staging, content)?; fs::rename(&staging, path).with_context(|| format!("installing {}", path.display())) } pub(super) async fn index(State(state): State) -> WebResult { serve_cache_path(&state, &format!("{CATALOG_DIR}/index.html")) } pub(super) async fn user_root( State(state): State, AxumPath(user): AxumPath, ) -> WebResult { if !state.snapshot.load().users.contains(&user) { return Err((StatusCode::NOT_FOUND, "not found".into())); } serve_cache_path( &state, &format!("{CATALOG_DIR}/users/{}.html", encode_path(&user)), ) } /// Unlike repos, only redirect known users: this route also catches /// top-level strays like `/favicon.ico`. pub(super) async fn user_redirect( State(state): State, OriginalUri(uri): OriginalUri, AxumPath(user): AxumPath, ) -> WebResult { if !state.snapshot.load().users.contains(&user) { return Err((StatusCode::NOT_FOUND, "not found".into())); } Ok(add_trailing_slash(OriginalUri(uri)).await) } pub(super) async fn stylesheet(State(state): State) -> WebResult { serve_cache_path(&state, "css/style.css") } pub(super) async fn js_asset( State(state): State, AxumPath(path): AxumPath, ) -> WebResult { embedded_asset(&state, &WEB_DIST, "js", &path) } pub(super) async fn font_asset( State(state): State, AxumPath(path): AxumPath, ) -> WebResult { embedded_asset(&state, &FONTS, "fonts", &path) } fn embedded_asset( state: &AppState, dir: &Dir<'_>, prefix: &str, path: &str, ) -> WebResult { if dir.get_file(path).is_none() { return Err((StatusCode::NOT_FOUND, "not found".into())); } serve_cache_path(state, &format!("{prefix}/{}", encode_path(path))) } pub(super) async fn add_trailing_slash(OriginalUri(uri): OriginalUri) -> Redirect { Redirect::permanent(&format!("{}/", uri.path())) } pub(super) async fn repo_root( State(state): State, OriginalUri(uri): OriginalUri, AxumPath((user, repo)): AxumPath<(String, String)>, ) -> WebResult { serve_repo(state, user, repo, String::new(), uri.path().to_owned()).await } pub(super) async fn repo_path( State(state): State, OriginalUri(uri): OriginalUri, AxumPath((user, repo, path)): AxumPath<(String, String, String)>, ) -> WebResult { serve_repo(state, user, repo, path, uri.path().to_owned()).await } async fn serve_repo( state: AppState, user: String, name: String, path: String, request_path: String, ) -> WebResult { let entry = state.resolve(&user, &name)?; let relative = requested_file(&path)?; let output = state.config.repo_cache(&entry.repository); if !output.join(STATE_FILE).is_file() { return Ok(( StatusCode::SERVICE_UNAVAILABLE, [(header::RETRY_AFTER, HeaderValue::from_static("10"))], "this repository's site is still being generated", ) .into_response()); } let target = output.join(&relative); if target.is_dir() { return Ok(Redirect::permanent(&format!("{request_path}/")).into_response()); } if !target.is_file() { return Err((StatusCode::NOT_FOUND, "page not found".into())); } serve_cache_path( &state, &format!( "{}/{}/{}", encode_path(&entry.repository.user), encode_path(&entry.repository.name), encode_path(&relative.to_string_lossy()), ), ) } fn serve_cache_path(state: &AppState, encoded_path: &str) -> WebResult { x_accel(&state.config.internal_prefix, encoded_path) } fn requested_file(path: &str) -> WebResult { if path.is_empty() { return Ok(PathBuf::from("index.html")); } if path == STATE_FILE || path == MANIFEST_FILE { return Err((StatusCode::NOT_FOUND, "page not found".into())); } let directory = path.ends_with('/'); let path = Path::new(path); if path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err((StatusCode::BAD_REQUEST, "invalid path".into())); } Ok(if directory { path.join("index.html") } else { path.to_owned() }) } #[cfg(test)] mod tests { use crate::generate::{MANIFEST_FILE, STATE_FILE}; use super::requested_file; #[test] fn generator_metadata_is_private() { assert!(requested_file(STATE_FILE).is_err()); assert!(requested_file(MANIFEST_FILE).is_err()); } }