anvilsign in

collin/anvil

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
15use anvil_core::App;
16use anvil_git::browse;
17use axum::{
18 Router,
19 extract::{Path, State},
20 http::header,
21 response::{IntoResponse, Redirect, Response},
22 routing::get,
23};
24use maud::{Markup, html};
25
26use crate::auth::CurrentUser;
27use crate::ui::{layout, not_found, resolve_repo, server_error};
28
29/// The branch pages are served from.
30pub const PAGES_REF: &str = "pages";
31
32/// Mount the pages routes.
33pub 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.
41async 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.
80async 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.
114fn 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.
126fn 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)]
155mod 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}