anvilsign in

collin/anvil · b6833865

feat: github-pages-style hosting from a repo's pages branch

Collin Richards · 2026-06-09 22:49 UTC · b6833865231aa6afb6d11a5589ff29162a04e005 · parent 5cba55f9 · browse files

modifiedcrates/anvil-web/src/lib.rs+2 −0
⋯ 15 unchanged lines
1616
1717 pub mod auth;
1818 pub mod git_http;
19+pub mod pages;
1920 pub mod ui;
2021
2122 /// Build the application router.
⋯ 3 unchanged lines
2526 .route("/-/login", get(auth::login_form).post(auth::login_submit))
2627 .route("/-/logout", post(auth::logout));
2728 router = ui::routes(router); // web UI, including `/`
29+ router = pages::routes(router); // static sites from `pages` branches
2830 router = git_http::routes(router); // smart-HTTP git endpoints
2931 router
3032 // Derives the per-request CSRF token so the layout can attach it to
⋯ 22 unchanged lines
addedcrates/anvil-web/src/pages.rs+169 −0
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+}
modifiedcrates/anvil-web/src/ui.rs+4 −3
⋯ 204 unchanged lines
205205 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
206206 }
207207
208-fn not_found(message: &str) -> Response {
208+pub(crate) fn not_found(message: &str) -> Response {
209209 (
210210 StatusCode::NOT_FOUND,
211211 layout(
⋯ 5 unchanged lines
217217 .into_response()
218218 }
219219
220-fn server_error(err: impl std::fmt::Display) -> Response {
220+pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
221221 tracing::error!("ui error: {err}");
222222 (
223223 StatusCode::INTERNAL_SERVER_ERROR,
⋯ 4 unchanged lines
228228
229229 /// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
230230 /// access for `viewer`. Private repos 404 for non-owners (no existence leak).
231-async fn resolve_repo(
231+pub(crate) async fn resolve_repo(
232232 app: &App,
233233 viewer: Option<&User>,
234234 owner: &str,
⋯ 472 unchanged lines
707707 @if meta.is_private { " " span.pill { "private" } }
708708 }
709709 a.btn href=(format!("/{owner}/{repo}/ci")) { "CI" }
710+ a.btn href=(format!("/{owner}/{repo}/pages")) { "Pages" }
710711 @if can_write {
711712 a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
712713 }
⋯ 481 unchanged lines