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