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