| 1 | + | //! GitHub-pages-style static hosting, served from a repository's `pages` |
| 2 | + | //! branch. |
| 3 | + | //! |
| 4 | + | //! Push a branch named `pages`; each top-level directory of it is a site |
| 5 | + | //! (e.g. `rustdoc/`, `book/`), so one repo can host several generated outputs |
| 6 | + | //! side by side. `/{owner}/{repo}/pages` lists the sites; paths under it are |
| 7 | + | //! served raw with a content type guessed from the extension, resolving |
| 8 | + | //! `index.html` for directories. Access follows repository visibility |
| 9 | + | //! (private repos 404 to non-readers). |
| 10 | + | //! |
| 11 | + | //! Pages serve user-authored HTML/JS from the forge origin by design — fine |
| 12 | + | //! for the supported single-tenant stance; see `docs/untrusted-mode.md` §2 |
| 13 | + | //! before hosting untrusted users. |
| 14 | + | |
| 15 | + | use anvil_core::App; |
| 16 | + | use anvil_git::browse; |
| 17 | + | use axum::{ |
| 18 | + | Router, |
| 19 | + | extract::{Path, State}, |
| 20 | + | http::header, |
| 21 | + | response::{IntoResponse, Redirect, Response}, |
| 22 | + | routing::get, |
| 23 | + | }; |
| 24 | + | use maud::{Markup, html}; |
| 25 | + | |
| 26 | + | use crate::auth::CurrentUser; |
| 27 | + | use crate::ui::{layout, not_found, resolve_repo, server_error}; |
| 28 | + | |
| 29 | + | /// The branch pages are served from. |
| 30 | + | pub const PAGES_REF: &str = "pages"; |
| 31 | + | |
| 32 | + | /// Mount the pages routes. |
| 33 | + | pub fn routes(router: Router<App>) -> Router<App> { |
| 34 | + | router |
| 35 | + | .route("/{owner}/{repo}/pages", get(pages_index)) |
| 36 | + | .route("/{owner}/{repo}/pages/{*path}", get(pages_serve)) |
| 37 | + | } |
| 38 | + | |
| 39 | + | /// `GET /{owner}/{repo}/pages` — list the repository's sites (the top-level |
| 40 | + | /// entries of the `pages` branch), or how to publish one. |
| 41 | + | async fn pages_index( |
| 42 | + | State(app): State<App>, |
| 43 | + | CurrentUser(user): CurrentUser, |
| 44 | + | Path((owner, repo)): Path<(String, String)>, |
| 45 | + | ) -> Result<Markup, Response> { |
| 46 | + | let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; |
| 47 | + | // A missing `pages` branch is the common case, not an error. |
| 48 | + | let entries = browse::list_tree(&repo_path, PAGES_REF, "").unwrap_or_default(); |
| 49 | + | Ok(layout( |
| 50 | + | &format!("{owner}/{repo}: pages"), |
| 51 | + | user.as_ref(), |
| 52 | + | html! { |
| 53 | + | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · pages" } |
| 54 | + | @if entries.is_empty() { |
| 55 | + | p.muted { |
| 56 | + | "No pages yet. Push a branch named " code { "pages" } |
| 57 | + | " — each top-level directory becomes a site:" |
| 58 | + | } |
| 59 | + | p { code { "git push origin my-built-site-branch:pages" } } |
| 60 | + | } @else { |
| 61 | + | p.muted { "Sites served from the " code { "pages" } " branch." } |
| 62 | + | div.box { |
| 63 | + | @for e in &entries { |
| 64 | + | div.row { |
| 65 | + | a.entry href=(format!("/{owner}/{repo}/pages/{}{}", e.name, if e.is_dir { "/" } else { "" })) { |
| 66 | + | span.icon { (if e.is_dir { "[ ]" } else { "·" }) } |
| 67 | + | (e.name) @if e.is_dir { "/" } |
| 68 | + | } |
| 69 | + | } |
| 70 | + | } |
| 71 | + | } |
| 72 | + | } |
| 73 | + | }, |
| 74 | + | )) |
| 75 | + | } |
| 76 | + | |
| 77 | + | /// `GET /{owner}/{repo}/pages/{*path}` — serve a file from the `pages` branch. |
| 78 | + | /// Directory paths resolve to their `index.html`, redirecting to the |
| 79 | + | /// trailing-slash form first so the site's relative links resolve. |
| 80 | + | async fn pages_serve( |
| 81 | + | State(app): State<App>, |
| 82 | + | CurrentUser(user): CurrentUser, |
| 83 | + | Path((owner, repo, path)): Path<(String, String, String)>, |
| 84 | + | ) -> Response { |
| 85 | + | let (repo_path, _) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { |
| 86 | + | Ok(v) => v, |
| 87 | + | Err(resp) => return resp, |
| 88 | + | }; |
| 89 | + | let trimmed = path.trim_end_matches('/'); |
| 90 | + | if trimmed.is_empty() { |
| 91 | + | return Redirect::to(&format!("/{owner}/{repo}/pages")).into_response(); |
| 92 | + | } |
| 93 | + | match browse::read_blob(&repo_path, PAGES_REF, trimmed) { |
| 94 | + | Ok(Some(bytes)) => file_response(trimmed, bytes), |
| 95 | + | Ok(None) => { |
| 96 | + | // Not a blob — a directory with an index.html, perhaps. |
| 97 | + | let index = format!("{trimmed}/index.html"); |
| 98 | + | match browse::read_blob(&repo_path, PAGES_REF, &index) { |
| 99 | + | Ok(Some(bytes)) if path.ends_with('/') => file_response(&index, bytes), |
| 100 | + | Ok(Some(_)) => { |
| 101 | + | Redirect::to(&format!("/{owner}/{repo}/pages/{trimmed}/")).into_response() |
| 102 | + | } |
| 103 | + | Ok(None) => not_found("no such page"), |
| 104 | + | Err(e) => server_error(e), |
| 105 | + | } |
| 106 | + | } |
| 107 | + | // The rev itself didn't resolve: no pages branch. |
| 108 | + | Err(_) => not_found("this repository has no pages branch"), |
| 109 | + | } |
| 110 | + | } |
| 111 | + | |
| 112 | + | /// Raw file response with a guessed content type. `nosniff` keeps browsers |
| 113 | + | /// from second-guessing it. |
| 114 | + | fn file_response(path: &str, bytes: Vec<u8>) -> Response { |
| 115 | + | ( |
| 116 | + | [ |
| 117 | + | (header::CONTENT_TYPE, content_type(path)), |
| 118 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), |
| 119 | + | ], |
| 120 | + | bytes, |
| 121 | + | ) |
| 122 | + | .into_response() |
| 123 | + | } |
| 124 | + | |
| 125 | + | /// Content type from the file extension; octet-stream when unknown. |
| 126 | + | fn content_type(path: &str) -> &'static str { |
| 127 | + | let ext = std::path::Path::new(path) |
| 128 | + | .extension() |
| 129 | + | .and_then(|e| e.to_str()) |
| 130 | + | .unwrap_or(""); |
| 131 | + | match ext.to_ascii_lowercase().as_str() { |
| 132 | + | "html" | "htm" => "text/html; charset=utf-8", |
| 133 | + | "css" => "text/css; charset=utf-8", |
| 134 | + | "js" | "mjs" => "application/javascript; charset=utf-8", |
| 135 | + | "json" => "application/json", |
| 136 | + | "svg" => "image/svg+xml", |
| 137 | + | "png" => "image/png", |
| 138 | + | "jpg" | "jpeg" => "image/jpeg", |
| 139 | + | "gif" => "image/gif", |
| 140 | + | "webp" => "image/webp", |
| 141 | + | "ico" => "image/x-icon", |
| 142 | + | "txt" | "md" => "text/plain; charset=utf-8", |
| 143 | + | "xml" => "application/xml", |
| 144 | + | "pdf" => "application/pdf", |
| 145 | + | "wasm" => "application/wasm", |
| 146 | + | "woff" => "font/woff", |
| 147 | + | "woff2" => "font/woff2", |
| 148 | + | "ttf" => "font/ttf", |
| 149 | + | "otf" => "font/otf", |
| 150 | + | _ => "application/octet-stream", |
| 151 | + | } |
| 152 | + | } |
| 153 | + | |
| 154 | + | #[cfg(test)] |
| 155 | + | mod tests { |
| 156 | + | use super::*; |
| 157 | + | |
| 158 | + | #[test] |
| 159 | + | fn content_types_by_extension() { |
| 160 | + | assert_eq!(content_type("a/index.html"), "text/html; charset=utf-8"); |
| 161 | + | assert_eq!(content_type("style.CSS"), "text/css; charset=utf-8"); |
| 162 | + | assert_eq!( |
| 163 | + | content_type("search.desc.js"), |
| 164 | + | "application/javascript; charset=utf-8" |
| 165 | + | ); |
| 166 | + | assert_eq!(content_type("font.woff2"), "font/woff2"); |
| 167 | + | assert_eq!(content_type("no-extension"), "application/octet-stream"); |
| 168 | + | } |
| 169 | + | } |