//! URL routes and the handler function for each page. //! //! URL layout (similar to cgit): //! //! | URL | page | //! |--------------------------------------|-------------------------------| //! | `/` | list of repos | //! | `/{repo}/` | summary | //! | `/{repo}/refs` | all branches and tags | //! | `/{repo}/log?h=branch&ofs=50` | commit log, 50 per page | //! | `/{repo}/commit/{id}` | one commit with its diff | //! | `/{repo}/tree/{path}?h=branch` | directory listing | //! | `/{repo}/blob/{path}?h=branch` | one file, highlighted | //! | `/{repo}/raw/{path}?h=branch` | one file, raw bytes | //! | `/{repo}/info/refs`, `/git-upload-pack` | `git clone` (see smart_http) | //! //! `?h=` picks a branch, tag or commit id; without it we use HEAD. use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{middleware, Router}; use serde::Deserialize; use std::sync::Arc; use crate::config::{RepoConfig, Repos, ServerConfig}; use crate::git::{self, PathContent}; use crate::highlight::Highlighter; use crate::markdown::{self, LinkBase}; use crate::pages::{self, BlobBody, IndexRow, RepoHeader, Tab}; use crate::{smart_http, util}; /// Commits per page on the log view. pub const LOG_PAGE_SIZE: usize = 50; /// Files larger than this are not rendered in the blob view. const MAX_BLOB_DISPLAY: usize = 2 * 1024 * 1024; const STYLE_CSS: &str = include_str!("../static/style.css"); /// Shared, read-only state handed to every request. pub struct AppState { pub server: ServerConfig, pub repos: Repos, pub highlighter: Highlighter, } pub type Shared = Arc; impl AppState { pub fn new(server: ServerConfig, repos: Repos) -> Shared { Arc::new(AppState { server, repos, highlighter: Highlighter::new() }) } /// Look up a repo by its URL name. Unknown names are a 404: this is the /// check that keeps the server from reading anything that isn't configured. pub fn repo(&self, name: &str) -> Result { self.repos.get(name).cloned().ok_or_else(|| AppError::not_found("no such repository")) } /// The URL people should pass to `git clone`. fn clone_url(&self, repo: &str, headers: &HeaderMap) -> String { let base = match &self.server.clone_base_url { Some(b) => b.trim_end_matches('/').to_string(), None => { let host = headers .get(header::HOST) .and_then(|h| h.to_str().ok()) .filter(|h| h.chars().all(|c| c.is_ascii_alphanumeric() || ".:-[]".contains(c))) .unwrap_or(&self.server.listen); format!("http://{host}") } }; format!("{base}/{repo}") } } pub fn router(state: Shared) -> Router { Router::new() .route("/", get(index)) .route("/static/style.css", get(style_css)) .route("/static/highlight.css", get(highlight_css)) .route("/{repo}", get(add_slash)) .route("/{repo}/", get(summary)) .route("/{repo}/refs", get(refs)) .route("/{repo}/log", get(log)) .route("/{repo}/commit/{id}", get(commit)) .route("/{repo}/tree", get(tree_root)) .route("/{repo}/tree/", get(tree_root)) .route("/{repo}/tree/{*path}", get(tree)) .route("/{repo}/blob/{*path}", get(blob)) .route("/{repo}/raw/{*path}", get(raw)) // git clone over smart HTTP (read-only) .route("/{repo}/info/refs", get(smart_http::info_refs)) .route( "/{repo}/git-upload-pack", post(smart_http::upload_pack).layer(DefaultBodyLimit::max(smart_http::MAX_REQUEST_BYTES)), ) .route("/{repo}/git-receive-pack", post(smart_http::receive_pack_refused)) .fallback(|| async { AppError::not_found("page not found") }) .layer(middleware::map_response(security_headers)) .with_state(state) } /// Extra headers on every response. The Content-Security-Policy tells the /// browser to never run scripts on our pages, as a second line of defence /// behind HTML escaping. async fn security_headers(mut res: Response) -> Response { let h = res.headers_mut(); h.insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; style-src 'self'; img-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"), ); h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); h.insert(header::REFERRER_POLICY, HeaderValue::from_static("same-origin")); res } // ------------------------------------------------------------ errors -- /// An error page. Handlers return `Result<_, AppError>` and `?` does the rest. #[derive(Debug)] pub struct AppError { pub status: StatusCode, pub message: String, } impl AppError { pub fn not_found(msg: &str) -> AppError { AppError { status: StatusCode::NOT_FOUND, message: msg.to_string() } } } impl From for AppError { fn from(e: git::Error) -> Self { match e { git::Error::NotFound(m) => AppError { status: StatusCode::NOT_FOUND, message: m }, git::Error::Git(e) => { eprintln!("git error: {e}"); AppError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "internal error reading repository".into(), } } } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let page = pages::error("RustForge", self.status.as_u16(), &self.message); (self.status, Html(page.into_string())).into_response() } } /// libgit2 calls block the thread, so run them on tokio's blocking pool /// instead of stalling the async web server. async fn blocking( f: impl FnOnce() -> Result + Send + 'static, ) -> Result { tokio::task::spawn_blocking(f).await.map_err(|_| AppError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "internal error".into(), })? } #[derive(Deserialize, Default)] pub struct RevQuery { /// branch / tag / commit id pub h: Option, /// log offset pub ofs: Option, } impl RevQuery { fn rev(&self) -> Option<&str> { self.h.as_deref().filter(|s| !s.is_empty()) } } // ---------------------------------------------------------- handlers -- async fn style_css() -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], STYLE_CSS) } async fn highlight_css(State(st): State) -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], st.highlighter.css.clone()) } async fn add_slash(State(st): State, Path(repo): Path) -> Result { let rc = st.repo(&repo)?; Ok(Redirect::permanent(&format!("/{}/", rc.name))) } async fn index(State(st): State) -> Result, AppError> { blocking(move || { let now = util::now_unix(); let rows: Vec = st .repos .iter() .map(|rc| IndexRow { name: rc.name.clone(), description: rc.description.clone(), idle: git::open(&rc.path).ok().and_then(|r| git::last_change(&r)).map(|t| util::age(now - t)), }) .collect(); Ok(Html(pages::index(&st.server.site_title, &rows).into_string())) }) .await } fn header<'a>(st: &'a AppState, rc: &'a RepoConfig, tab: Tab, rev: Option<&'a str>) -> RepoHeader<'a> { RepoHeader { site_title: &st.server.site_title, name: &rc.name, description: &rc.description, tab, rev } } async fn summary( State(st): State, Path(repo): Path, headers: HeaderMap, ) -> Result, AppError> { let rc = st.repo(&repo)?; let clone_url = st.clone_url(&rc.name, &headers); blocking(move || { let r = git::open(&rc.path)?; let mut branches = git::branches(&r)?; let mut tags = git::tags(&r)?; branches.truncate(10); tags.truncate(10); let (commits, readme) = match git::resolve(&r, None) { Ok(head) => { let (commits, _) = git::log(&r, head, 0, 10)?; let base_links = format!("/{}/tree/", rc.name); let base_images = format!("/{}/raw/", rc.name); let base = LinkBase { links: &base_links, images: &base_images }; let readme = git::readme(&r, head).map(|(name, text)| { let html = if name.ends_with(".md") || name.ends_with(".markdown") { markdown::render(&text, Some(&base)) } else { format!("
{}
", crate::highlight::escape(&text)) }; (name, html) }); (commits, readme) } Err(_) => (Vec::new(), None), // empty repo }; let s = pages::Summary { clone_url, branches: &branches, tags: &tags, commits: &commits, readme }; Ok(Html(pages::summary(&header(&st, &rc, Tab::Summary, None), &s).into_string())) }) .await } async fn refs(State(st): State, Path(repo): Path) -> Result, AppError> { let rc = st.repo(&repo)?; blocking(move || { let r = git::open(&rc.path)?; let page = pages::refs(&header(&st, &rc, Tab::Refs, None), &git::branches(&r)?, &git::tags(&r)?); Ok(Html(page.into_string())) }) .await } async fn log( State(st): State, Path(repo): Path, Query(q): Query, ) -> Result, AppError> { let rc = st.repo(&repo)?; blocking(move || { let r = git::open(&rc.path)?; let start = git::resolve(&r, q.rev())?; let ofs = q.ofs.unwrap_or(0); let (commits, more) = git::log(&r, start, ofs, LOG_PAGE_SIZE)?; let page = pages::log(&header(&st, &rc, Tab::Log, q.rev()), &commits, ofs, LOG_PAGE_SIZE, more); Ok(Html(page.into_string())) }) .await } async fn commit( State(st): State, Path((repo, id)): Path<(String, String)>, ) -> Result, AppError> { let rc = st.repo(&repo)?; blocking(move || { let r = git::open(&rc.path)?; let oid = git::resolve(&r, Some(&id))?; let info = git::commit(&r, oid)?; let diff = git::commit_diff(&r, oid)?; Ok(Html(pages::commit(&header(&st, &rc, Tab::Commit, None), &info, &diff).into_string())) }) .await } async fn tree_root( State(st): State, Path(repo): Path, Query(q): Query, ) -> Result { show_path(st, repo, String::new(), q).await } async fn tree( State(st): State, Path((repo, path)): Path<(String, String)>, Query(q): Query, ) -> Result { show_path(st, repo, path, q).await } async fn blob( State(st): State, Path((repo, path)): Path<(String, String)>, Query(q): Query, ) -> Result { show_path(st, repo, path, q).await } /// Shared by tree and blob: a directory shows a listing, a file shows its /// contents, whichever URL was used. async fn show_path(st: Shared, repo: String, raw_path: String, q: RevQuery) -> Result { let rc = st.repo(&repo)?; let path = util::clean_repo_path(&raw_path).ok_or_else(|| AppError::not_found("invalid path"))?; blocking(move || { let r = git::open(&rc.path)?; let commit = git::resolve(&r, q.rev())?; let content = git::path_content(&r, commit, &path)?; let page = match content { PathContent::Dir(entries) => pages::tree(&header(&st, &rc, Tab::Tree, q.rev()), &path, &entries), PathContent::File { data, .. } => { let file_name = path.rsplit('/').next().unwrap_or(&path); let body = if data.len() > MAX_BLOB_DISPLAY { BlobBody::TooLarge } else if git::is_binary(&data) { BlobBody::Binary } else { let text = String::from_utf8_lossy(&data); BlobBody::Text { html: st.highlighter.highlight(file_name, &text), lines: text.lines().count(), } }; pages::blob(&header(&st, &rc, Tab::Tree, q.rev()), &path, data.len(), &body) } }; Ok(Html(page.into_string()).into_response()) }) .await } async fn raw( State(st): State, Path((repo, path)): Path<(String, String)>, Query(q): Query, ) -> Result { let rc = st.repo(&repo)?; let path = util::clean_repo_path(&path).ok_or_else(|| AppError::not_found("invalid path"))?; blocking(move || { let r = git::open(&rc.path)?; let commit = git::resolve(&r, q.rev())?; match git::path_content(&r, commit, &path)? { PathContent::Dir(_) => Err(AppError::not_found("that path is a directory")), PathContent::File { data, .. } => { let ctype = raw_content_type(&path, &data); let mut res = (StatusCode::OK, data).into_response(); res.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype)); if ctype == "application/octet-stream" { res.headers_mut() .insert(header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment")); } Ok(res) } } }) .await } /// Raw files are served as plain text, a few safe image types, or a download. /// Never as text/html or SVG, which a browser would execute. pub fn raw_content_type(path: &str, data: &[u8]) -> &'static str { let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", _ if !git::is_binary(data) && std::str::from_utf8(data).is_ok() => "text/plain; charset=utf-8", _ => "application/octet-stream", } } #[cfg(test)] mod tests { use super::*; #[test] fn raw_types() { assert_eq!(raw_content_type("a.png", b"\x89PNG\0"), "image/png"); assert_eq!(raw_content_type("index.html", b"