use std::fs; use std::path::{Component, Path, PathBuf}; use anyhow::Result; use axum::body::Body; use axum::extract::{OriginalUri, Path as AxumPath, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use include_dir::{Dir, include_dir}; use crate::generate::{MANIFEST_FILE, STATE_FILE, STYLESHEET}; use crate::render::encode_path; use super::{AppState, WebResult, internal, 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"); 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 only on change to keep the // mtime-derived ETags stable across restarts. 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(()) } fn write_asset(path: &Path, content: &[u8]) -> Result<()> { if fs::read(path).ok().as_deref() != Some(content) { fs::create_dir_all(path.parent().expect("asset path has a parent"))?; fs::write(path, content)?; } Ok(()) } pub(super) async fn index(State(state): State) -> Response { let html = state.snapshot.load().catalog_html.clone(); ( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], Body::from(html), ) .into_response() } 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() { state.build(&entry).await.map_err(internal)?; } 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()); } }