anvilsign in

collin/anvil

1//! Server-rendered web UI (Maud): repo list, repo overview, tree browsing, and
2//! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
3//! progressive enhancement is a follow-up.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8
9use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users};
10use anvil_git::browse::{self, ChangeKind, FileChange};
11use axum::{
12 Form, Router,
13 extract::{Path, State},
14 http::{StatusCode, header},
15 response::{IntoResponse, Redirect, Response},
16 routing::{get, post},
17};
18use maud::{DOCTYPE, Markup, PreEscaped, html};
19use similar::{ChangeTag, TextDiff};
20use syntect::easy::HighlightLines;
21use syntect::highlighting::{Theme, ThemeSet};
22use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
23use syntect::parsing::SyntaxSet;
24use time::OffsetDateTime;
25
26use crate::auth::{CSRF_FIELD, Csrf, CurrentUser, verify_csrf};
27
28const STYLE: &str = r#"
29:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
30* { box-sizing:border-box; }
31body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
32a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
33header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
34.container { max-width:980px; margin:0 auto; padding:0 16px; }
35header.top .container { display:flex; align-items:center; gap:12px; }
36.brand { font-weight:700; font-size:16px; color:var(--fg); }
37main { padding:24px 0; }
38h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
39.muted { color:var(--muted); }
40.repo-list { list-style:none; padding:0; margin:0; }
41.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
42.repo-list .name { font-size:16px; font-weight:600; }
43.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
44.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
45.box .row:first-child { border-top:0; }
46.box .row a.entry { display:flex; gap:8px; align-items:center; }
47.icon { width:16px; color:var(--muted); }
48table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
49table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
50table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
51.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
52.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
53.clone-tabs { display:flex; gap:4px; margin-left:auto; }
54.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:2em; background:var(--bg); color:var(--muted); cursor:pointer; }
55.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
56.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
57.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
58.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
59.copy-btn:hover { color:var(--fg); }
60.copied-msg { display:none; color:#1a7f37; font-size:12px; }
61.clone.copied .copied-msg { display:inline; }
62.clone.copied .copy-btn { color:#1a7f37; }
63.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
64.pill { display:inline-block; background:var(--code-bg); border:1px solid var(--border); border-radius:2em; padding:1px 8px; font-size:12px; color:var(--muted); }
65.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
66.linkbtn:hover { text-decoration:underline; }
67.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; }
68.btn:hover { text-decoration:none; opacity:.92; }
69form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
70form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
71form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
72.latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
73.latest-commit + .box { border-radius:0 0 6px 6px; }
74.commit-list { list-style:none; padding:0; margin:0; }
75.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
76.commit-list li:first-child { border-top:0; }
77.sha { font:12px ui-monospace,monospace; color:var(--muted); }
78.file-diff { margin:16px 0; }
79.file-diff .head { background:var(--code-bg); border:1px solid var(--border); border-bottom:0; border-radius:6px 6px 0 0; padding:6px 12px; font:12px ui-monospace,monospace; }
80table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
81table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
82table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
83table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
84.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
85.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
86.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
87.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
88.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
89.log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; }
90footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
91"#;
92
93/// Clipboard icon for the clone "copy" button.
94const CLIPBOARD_SVG: &str = r#"<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></svg>"#;
95
96/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
97/// Registered once on `document`, so it survives htmx body swaps.
98const CLONE_JS: &str = r#"
99(function(){
100 function copyText(t){
101 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
102 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
103 document.body.appendChild(ta); ta.focus(); ta.select();
104 try{document.execCommand('copy')}catch(e){}
105 document.body.removeChild(ta); return Promise.resolve();
106 }
107 document.addEventListener('click', function(e){
108 var tab=e.target.closest('.clone-tab');
109 if(tab){
110 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
111 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
112 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
113 return;
114 }
115 var copy=e.target.closest('.copy-btn');
116 if(copy){
117 var box=copy.closest('.clone');
118 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
119 box.classList.add('copied');
120 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
121 });
122 }
123 });
124})();
125"#;
126
127/// Mount the web UI routes.
128pub fn routes(router: Router<App>) -> Router<App> {
129 router
130 .route("/", get(home))
131 .route("/-/settings", get(account_settings))
132 .route("/-/settings/keys", post(add_ssh_key))
133 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
134 .route("/-/new", get(new_repo_form).post(new_repo_submit))
135 .route("/{username}", get(user_profile))
136 .route(
137 "/{owner}/{repo}/settings",
138 get(repo_settings).post(repo_settings_submit),
139 )
140 .route("/{owner}/{repo}", get(repo_index))
141 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
142 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
143 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
144 .route("/{owner}/{repo}/commits/{rev}", get(commits))
145 .route("/{owner}/{repo}/commit/{id}", get(commit))
146 .route("/{owner}/{repo}/ci", get(ci_runs))
147 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
148 .route("/-/static/htmx.min.js", get(htmx_js))
149}
150
151/// Serve the vendored htmx script (embedded in the binary).
152async fn htmx_js() -> Response {
153 (
154 [(
155 header::CONTENT_TYPE,
156 "application/javascript; charset=utf-8",
157 )],
158 include_str!("../assets/htmx.min.js"),
159 )
160 .into_response()
161}
162
163pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
164 // Attach the session's CSRF token to every htmx request as a header, so any
165 // JS-driven action carries it without a hidden field. Omitted (no attribute)
166 // when unauthenticated. The token is hex, so it needs no JSON escaping.
167 let csrf = crate::auth::current_csrf();
168 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
169 html! {
170 (DOCTYPE)
171 html lang="en" {
172 head {
173 meta charset="utf-8";
174 meta name="viewport" content="width=device-width, initial-scale=1";
175 title { (title) " · anvil" }
176 style { (PreEscaped(STYLE)) }
177 }
178 body hx-boost="true" hx-headers=[hx_headers] {
179 header.top { div.container {
180 a.brand href="/" { "anvil" }
181 span style="margin-left:auto" {
182 @match user {
183 Some(u) => {
184 a href="/-/settings" { (u.username) }
185 " · "
186 form method="post" action="/-/logout" style="display:inline" {
187 button.linkbtn type="submit" { "sign out" }
188 }
189 }
190 None => { a href="/-/login" { "sign in" } }
191 }
192 }
193 } }
194 main { div.container { (body) } }
195 footer { div.container { "anvil — a minimal git forge" } }
196 script src="/-/static/htmx.min.js" {}
197 script { (PreEscaped(CLONE_JS)) }
198 }
199 }
200 }
201}
202
203/// Hidden CSRF token field for embedding inside a mutating `<form>`.
204pub(crate) fn csrf_input(token: &str) -> Markup {
205 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
206}
207
208fn not_found(message: &str) -> Response {
209 (
210 StatusCode::NOT_FOUND,
211 layout(
212 "Not found",
213 None,
214 html! { h1 { "Not found" } p.muted { (message) } },
215 ),
216 )
217 .into_response()
218}
219
220fn server_error(err: impl std::fmt::Display) -> Response {
221 tracing::error!("ui error: {err}");
222 (
223 StatusCode::INTERNAL_SERVER_ERROR,
224 layout("Error", None, html! { h1 { "Something went wrong" } }),
225 )
226 .into_response()
227}
228
229/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
230/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
231async fn resolve_repo(
232 app: &App,
233 viewer: Option<&User>,
234 owner: &str,
235 name: &str,
236) -> Result<(PathBuf, Repository), Response> {
237 let owner_user = users::find_by_username(&app.db, owner)
238 .await
239 .map_err(server_error)?
240 .ok_or_else(|| not_found("no such user"))?;
241 let repo = repos::find(&app.db, owner_user.id, name)
242 .await
243 .map_err(server_error)?
244 .ok_or_else(|| not_found("no such repository"))?;
245 if !access::can_read(&repo, viewer) {
246 return Err(not_found("no such repository"));
247 }
248 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
249 if !path.exists() {
250 return Err(not_found("repository not found on disk"));
251 }
252 Ok((path, repo))
253}
254
255/// `GET /` — list repositories visible to the current user.
256async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
257 let all = repos::list_all_with_owner(&app.db)
258 .await
259 .map_err(server_error)?;
260 let repos: Vec<_> = all
261 .into_iter()
262 .filter(|r| {
263 !r.is_private
264 || user
265 .as_ref()
266 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
267 })
268 .collect();
269 Ok(layout(
270 "Repositories",
271 user.as_ref(),
272 html! {
273 div style="display:flex;align-items:center" {
274 h1 style="margin-right:auto" { "Repositories" }
275 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
276 }
277 @if repos.is_empty() {
278 p.muted {
279 "No repositories yet. "
280 @if user.is_some() { a href="/-/new" { "Create one" } "." }
281 @else { "Sign in to create one." }
282 }
283 } @else {
284 ul.repo-list {
285 @for r in &repos {
286 li {
287 div.name {
288 a href=(format!("/{}", r.owner)) { (r.owner) }
289 "/"
290 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
291 @if r.is_private { " " span.pill { "private" } }
292 }
293 @if !r.description.is_empty() { div.muted { (r.description) } }
294 }
295 }
296 }
297 }
298 },
299 ))
300}
301
302/// `GET /{username}` — a user's profile: their repositories (public to all;
303/// private only to themselves or an admin).
304async fn user_profile(
305 State(app): State<App>,
306 CurrentUser(viewer): CurrentUser,
307 Path(username): Path<String>,
308) -> Result<Markup, Response> {
309 let owner = users::find_by_username(&app.db, &username)
310 .await
311 .map_err(server_error)?
312 .ok_or_else(|| not_found("no such user"))?;
313 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
314 .await
315 .map_err(server_error)?
316 .into_iter()
317 .filter(|r| access::can_read(r, viewer.as_ref()))
318 .collect();
319 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
320
321 Ok(layout(
322 &owner.username,
323 viewer.as_ref(),
324 html! {
325 div style="display:flex;align-items:center" {
326 h1 style="margin-right:auto" { (owner.username) }
327 @if is_self { a.btn href="/-/new" { "New repository" } }
328 }
329 h2 { "Repositories" }
330 @if visible.is_empty() {
331 p.muted { "No repositories." }
332 } @else {
333 ul.repo-list {
334 @for r in &visible {
335 li {
336 div.name {
337 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
338 @if r.is_private { " " span.pill { "private" } }
339 }
340 @if !r.description.is_empty() { div.muted { (r.description) } }
341 }
342 }
343 }
344 }
345 },
346 ))
347}
348
349#[derive(serde::Deserialize)]
350struct AddKeyForm {
351 #[serde(default)]
352 title: String,
353 key: String,
354 #[serde(default)]
355 csrf: String,
356}
357
358/// `GET /settings` — account settings: profile + SSH keys.
359async fn account_settings(
360 State(app): State<App>,
361 CurrentUser(user): CurrentUser,
362 csrf: Csrf,
363) -> Response {
364 let Some(user) = user else {
365 return Redirect::to("/-/login").into_response();
366 };
367 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
368 Ok(keys) => keys,
369 Err(e) => return server_error(e),
370 };
371 account_page(&user, &keys, None, &csrf.0).into_response()
372}
373
374/// `POST /settings/keys` — register an SSH public key for the current user.
375async fn add_ssh_key(
376 State(app): State<App>,
377 CurrentUser(user): CurrentUser,
378 csrf: Csrf,
379 Form(form): Form<AddKeyForm>,
380) -> Response {
381 let Some(user) = user else {
382 return Redirect::to("/-/login").into_response();
383 };
384 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
385 return resp;
386 }
387 let result = match ssh_keys::parse_public_key(&form.key) {
388 Ok((fingerprint, content)) => {
389 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
390 .await
391 .map(|_| ())
392 }
393 Err(e) => Err(e),
394 };
395 match result {
396 Ok(()) => Redirect::to("/-/settings").into_response(),
397 Err(e) => {
398 let keys = ssh_keys::list_by_user(&app.db, user.id)
399 .await
400 .unwrap_or_default();
401 (
402 StatusCode::BAD_REQUEST,
403 account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
404 )
405 .into_response()
406 }
407 }
408}
409
410/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
411async fn delete_ssh_key(
412 State(app): State<App>,
413 CurrentUser(user): CurrentUser,
414 csrf: Csrf,
415 Path(id): Path<i64>,
416 Form(form): Form<crate::auth::CsrfForm>,
417) -> Response {
418 let Some(user) = user else {
419 return Redirect::to("/-/login").into_response();
420 };
421 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
422 return resp;
423 }
424 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
425 return server_error(e);
426 }
427 Redirect::to("/-/settings").into_response()
428}
429
430fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
431 layout(
432 "Account settings",
433 Some(user),
434 html! {
435 h1 { "Account settings" }
436 p.muted {
437 "Signed in as " strong { (user.username) }
438 @if !user.email.is_empty() { " · " (user.email) }
439 }
440
441 h2 { "SSH keys" }
442 p.muted { "Add a public key to clone and push over SSH." }
443 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
444 @if keys.is_empty() {
445 p.muted { "No SSH keys yet." }
446 } @else {
447 div.box {
448 @for k in keys {
449 div.row {
450 div {
451 @if !k.title.is_empty() { strong { (k.title) } " " }
452 span.sha { (k.fingerprint) }
453 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
454 }
455 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
456 (csrf_input(csrf))
457 button.linkbtn type="submit" { "delete" }
458 }
459 }
460 }
461 }
462 }
463
464 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
465 (csrf_input(csrf))
466 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
467 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
468 p { button.btn type="submit" { "Add SSH key" } }
469 }
470 },
471 )
472}
473
474fn forbidden() -> Response {
475 (
476 StatusCode::FORBIDDEN,
477 layout(
478 "Forbidden",
479 None,
480 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
481 ),
482 )
483 .into_response()
484}
485
486#[derive(serde::Deserialize)]
487struct NewRepoForm {
488 name: String,
489 #[serde(default)]
490 description: String,
491 private: Option<String>,
492 #[serde(default)]
493 csrf: String,
494}
495
496#[derive(serde::Deserialize)]
497struct SettingsForm {
498 #[serde(default)]
499 description: String,
500 private: Option<String>,
501 #[serde(default)]
502 csrf: String,
503}
504
505/// `GET /new` — new-repository form (requires login).
506async fn new_repo_form(CurrentUser(user): CurrentUser, csrf: Csrf) -> Response {
507 let Some(user) = user else {
508 return Redirect::to("/-/login").into_response();
509 };
510 new_repo_page(&user, None, "", "", false, &csrf.0).into_response()
511}
512
513/// `POST /new` — create a repository owned by the current user.
514async fn new_repo_submit(
515 State(app): State<App>,
516 CurrentUser(user): CurrentUser,
517 csrf: Csrf,
518 Form(form): Form<NewRepoForm>,
519) -> Response {
520 let Some(user) = user else {
521 return Redirect::to("/-/login").into_response();
522 };
523 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
524 return resp;
525 }
526 let private = form.private.is_some();
527 match repos::create(
528 &app.db,
529 &app.config.repositories_dir(),
530 &user,
531 &form.name,
532 &form.description,
533 private,
534 )
535 .await
536 {
537 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
538 Err(e) => (
539 StatusCode::BAD_REQUEST,
540 new_repo_page(
541 &user,
542 Some(&e.to_string()),
543 &form.name,
544 &form.description,
545 private,
546 &csrf.0,
547 ),
548 )
549 .into_response(),
550 }
551}
552
553fn new_repo_page(
554 user: &User,
555 error: Option<&str>,
556 name: &str,
557 description: &str,
558 private: bool,
559 csrf: &str,
560) -> Markup {
561 layout(
562 "New repository",
563 Some(user),
564 html! {
565 h1 { "New repository" }
566 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
567 form.stack method="post" action="/-/new" {
568 (csrf_input(csrf))
569 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
570 p { label { "Description" br; input type="text" name="description" value=(description); } }
571 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
572 p { button.btn type="submit" { "Create repository" } }
573 }
574 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
575 },
576 )
577}
578
579/// Load a repo for an owner-only settings action, enforcing write access.
580async fn resolve_for_settings(
581 app: &App,
582 viewer: Option<&User>,
583 owner: &str,
584 name: &str,
585) -> Result<Repository, Response> {
586 let owner_user = users::find_by_username(&app.db, owner)
587 .await
588 .map_err(server_error)?
589 .ok_or_else(|| not_found("no such repository"))?;
590 let repo = repos::find(&app.db, owner_user.id, name)
591 .await
592 .map_err(server_error)?
593 .ok_or_else(|| not_found("no such repository"))?;
594 if !access::can_read(&repo, viewer) {
595 return Err(not_found("no such repository"));
596 }
597 if !access::can_write(&repo, viewer) {
598 return Err(forbidden());
599 }
600 Ok(repo)
601}
602
603/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
604async fn repo_settings(
605 State(app): State<App>,
606 CurrentUser(user): CurrentUser,
607 csrf: Csrf,
608 Path((owner, repo)): Path<(String, String)>,
609) -> Response {
610 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
611 Ok(m) => m,
612 Err(resp) => return resp,
613 };
614 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
615}
616
617/// `POST /{owner}/{repo}/settings` — update description / visibility.
618async fn repo_settings_submit(
619 State(app): State<App>,
620 CurrentUser(user): CurrentUser,
621 csrf: Csrf,
622 Path((owner, repo)): Path<(String, String)>,
623 Form(form): Form<SettingsForm>,
624) -> Response {
625 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
626 Ok(m) => m,
627 Err(resp) => return resp,
628 };
629 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
630 return resp;
631 }
632 if let Err(e) =
633 repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
634 {
635 return server_error(e);
636 }
637 Redirect::to(&format!("/{owner}/{repo}")).into_response()
638}
639
640fn settings_page(
641 user: Option<&User>,
642 owner: &str,
643 repo: &str,
644 meta: &Repository,
645 error: Option<&str>,
646 csrf: &str,
647) -> Markup {
648 layout(
649 &format!("{owner}/{repo}: settings"),
650 user,
651 html! {
652 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
653 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
654 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
655 (csrf_input(csrf))
656 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
657 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
658 p { button.btn type="submit" { "Save changes" } }
659 }
660 },
661 )
662}
663
664fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
665 let http = app.config.http_clone_url(owner, name);
666 let ssh = app
667 .config
668 .ssh
669 .enabled
670 .then(|| app.config.ssh_clone_url(owner, name));
671 html! {
672 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
673 div.clone-head {
674 span.muted { "Clone" }
675 div.clone-tabs {
676 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
677 @if ssh.is_some() {
678 button.clone-tab type="button" data-proto="ssh" { "SSH" }
679 }
680 }
681 }
682 div.clone-cmd {
683 code { "git clone " (http) }
684 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
685 (PreEscaped(CLIPBOARD_SVG))
686 }
687 span.copied-msg { "Copied!" }
688 }
689 }
690 }
691}
692
693/// `GET /{owner}/{repo}` — repository overview with the root tree.
694async fn repo_index(
695 State(app): State<App>,
696 CurrentUser(user): CurrentUser,
697 Path((owner, repo)): Path<(String, String)>,
698) -> Result<Markup, Response> {
699 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
700 let overview = browse::overview(&path).map_err(server_error)?;
701
702 let can_write = access::can_write(&meta, user.as_ref());
703 let header = html! {
704 div style="display:flex;align-items:center;gap:8px" {
705 h1 style="margin-right:auto" {
706 a href=(format!("/{owner}")) { (owner) } " / " (repo)
707 @if meta.is_private { " " span.pill { "private" } }
708 }
709 a.btn href=(format!("/{owner}/{repo}/ci")) { "CI" }
710 @if can_write {
711 a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
712 }
713 }
714 @if !meta.description.is_empty() { p.muted { (meta.description) } }
715 p {
716 span.pill { (overview.branches.len()) " branches" }
717 " "
718 span.pill { (overview.tags.len()) " tags" }
719 }
720 (clone_box(&app, &owner, &repo))
721 };
722
723 if overview.is_empty {
724 return Ok(layout(
725 &format!("{owner}/{repo}"),
726 user.as_ref(),
727 html! {
728 (header)
729 p.muted { "This repository is empty. Push to it to get started." }
730 },
731 ));
732 }
733
734 let rev = overview
735 .default_branch
736 .clone()
737 .unwrap_or_else(|| "HEAD".to_string());
738 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
739 let latest = browse::commit_log(&path, &rev, 1)
740 .map_err(server_error)?
741 .into_iter()
742 .next();
743
744 Ok(layout(
745 &format!("{owner}/{repo}"),
746 user.as_ref(),
747 html! {
748 (header)
749 p.muted {
750 "Branch: " (rev) " · "
751 a href=(format!("/{owner}/{repo}/commits/{rev}")) { "commits" }
752 }
753 @if let Some(c) = &latest {
754 div.latest-commit {
755 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
756 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
757 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
758 }
759 }
760 (tree_table(&owner, &repo, &rev, "", &entries))
761 },
762 ))
763}
764
765async fn tree_root(
766 State(app): State<App>,
767 user: CurrentUser,
768 Path((owner, repo, rev)): Path<(String, String, String)>,
769) -> Result<Markup, Response> {
770 render_tree(&app, user, &owner, &repo, &rev, "").await
771}
772
773async fn tree_path(
774 State(app): State<App>,
775 user: CurrentUser,
776 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
777) -> Result<Markup, Response> {
778 render_tree(&app, user, &owner, &repo, &rev, &path).await
779}
780
781async fn render_tree(
782 app: &App,
783 CurrentUser(user): CurrentUser,
784 owner: &str,
785 repo: &str,
786 rev: &str,
787 path: &str,
788) -> Result<Markup, Response> {
789 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
790 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
791 Ok(layout(
792 &format!("{owner}/{repo}: {path}"),
793 user.as_ref(),
794 html! {
795 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
796 (breadcrumbs(owner, repo, rev, path, false))
797 (tree_table(owner, repo, rev, path, &entries))
798 },
799 ))
800}
801
802/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file.
803async fn blob(
804 State(app): State<App>,
805 CurrentUser(user): CurrentUser,
806 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
807) -> Result<Markup, Response> {
808 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
809 let bytes = browse::read_blob(&repo_path, &rev, &path)
810 .map_err(server_error)?
811 .ok_or_else(|| not_found("file not found"))?;
812
813 let body = if is_binary(&bytes) {
814 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
815 } else {
816 let text = String::from_utf8_lossy(&bytes);
817 let lines = highlight(&path, &text);
818 html! {
819 table.code {
820 @for (i, line) in lines.iter().enumerate() {
821 tr {
822 td.ln { (i + 1) }
823 td { (PreEscaped(line)) }
824 }
825 }
826 }
827 }
828 };
829
830 Ok(layout(
831 &format!("{owner}/{repo}: {path}"),
832 user.as_ref(),
833 html! {
834 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
835 (breadcrumbs(&owner, &repo, &rev, &path, true))
836 div.box style="overflow-x:auto" { (body) }
837 },
838 ))
839}
840
841/// Render a tree listing as a box of rows; directories link to `tree`, files to `blob`.
842fn tree_table(
843 owner: &str,
844 repo: &str,
845 rev: &str,
846 path: &str,
847 entries: &[browse::TreeEntry],
848) -> Markup {
849 let join = |name: &str| {
850 if path.is_empty() {
851 name.to_string()
852 } else {
853 format!("{path}/{name}")
854 }
855 };
856 html! {
857 div.box {
858 @if !path.is_empty() {
859 div.row {
860 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
861 }
862 }
863 @for e in entries {
864 @let child = join(&e.name);
865 @let kind = if e.is_dir { "tree" } else { "blob" };
866 div.row {
867 a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) {
868 span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
869 (e.name) @if e.is_dir { "/" }
870 }
871 }
872 }
873 }
874 }
875}
876
877fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
878 match path.rsplit_once('/') {
879 Some((parent, _)) => format!("/{owner}/{repo}/tree/{rev}/{parent}"),
880 None => format!("/{owner}/{repo}/tree/{rev}"),
881 }
882}
883
884/// Path breadcrumbs. `is_blob` marks the final component as a file.
885fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
886 // Precompute (label, cumulative_path) for each path component.
887 let mut crumbs: Vec<(String, String)> = Vec::new();
888 let mut acc = String::new();
889 for part in path.split('/').filter(|p| !p.is_empty()) {
890 if !acc.is_empty() {
891 acc.push('/');
892 }
893 acc.push_str(part);
894 crumbs.push((part.to_string(), acc.clone()));
895 }
896 let last = crumbs.len();
897 html! {
898 div.crumbs {
899 a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) }
900 @for (i, (label, cum)) in crumbs.iter().enumerate() {
901 " / "
902 @if i + 1 == last && is_blob {
903 span { (label) }
904 } @else {
905 a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) }
906 }
907 }
908 }
909 }
910}
911
912/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
913async fn commits(
914 State(app): State<App>,
915 CurrentUser(user): CurrentUser,
916 Path((owner, repo, rev)): Path<(String, String, String)>,
917) -> Result<Markup, Response> {
918 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
919 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
920
921 // Map each commit oid to its latest run status, for inline badges. One query
922 // for the repo's recent runs; first match wins (list is newest-first).
923 let runs = ci::list_by_repo(&app.db, meta.id, 200)
924 .await
925 .unwrap_or_default();
926 let mut status_of: HashMap<&str, &str> = HashMap::new();
927 for r in &runs {
928 status_of
929 .entry(r.commit.as_str())
930 .or_insert(r.status.as_str());
931 }
932
933 Ok(layout(
934 &format!("{owner}/{repo}: commits"),
935 user.as_ref(),
936 html! {
937 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
938 ul.commit-list {
939 @for c in &log {
940 li {
941 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
942 @if let Some(st) = status_of.get(c.id.as_str()) {
943 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
944 }
945 span { (c.summary) }
946 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
947 }
948 }
949 }
950 },
951 ))
952}
953
954/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
955async fn commit(
956 State(app): State<App>,
957 CurrentUser(user): CurrentUser,
958 Path((owner, repo, id)): Path<(String, String, String)>,
959) -> Result<Markup, Response> {
960 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
961 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
962 Ok(layout(
963 &format!("{owner}/{repo}: {}", detail.info.short),
964 user.as_ref(),
965 html! {
966 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
967 p { (detail.info.summary) }
968 p.muted {
969 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
970 span.sha { (detail.info.id) }
971 @if let Some(parent) = &detail.parent {
972 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
973 }
974 }
975 @if detail.changes.is_empty() {
976 p.muted { "No file changes." }
977 }
978 @for change in &detail.changes {
979 (render_file_diff(change))
980 }
981 },
982 ))
983}
984
985/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
986async fn ci_runs(
987 State(app): State<App>,
988 CurrentUser(user): CurrentUser,
989 Path((owner, repo)): Path<(String, String)>,
990) -> Result<Markup, Response> {
991 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
992 let runs = ci::list_by_repo(&app.db, meta.id, 100)
993 .await
994 .map_err(server_error)?;
995 Ok(layout(
996 &format!("{owner}/{repo}: CI"),
997 user.as_ref(),
998 html! {
999 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
1000 @if runs.is_empty() {
1001 p.muted {
1002 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
1003 " pipeline and push to trigger one."
1004 }
1005 } @else {
1006 div.box {
1007 @for r in &runs {
1008 div.row {
1009 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
1010 (status_badge(&r.status))
1011 span.sha { (short_commit(&r.commit)) }
1012 span { (r.ref_name) }
1013 }
1014 span.muted { (fmt_time(r.created_at)) }
1015 }
1016 }
1017 }
1018 }
1019 },
1020 ))
1021}
1022
1023/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1024async fn ci_run(
1025 State(app): State<App>,
1026 CurrentUser(user): CurrentUser,
1027 Path((owner, repo, id)): Path<(String, String, i64)>,
1028) -> Result<Markup, Response> {
1029 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1030 let run = ci::get(&app.db, id)
1031 .await
1032 .map_err(server_error)?
1033 .filter(|r| r.repo_id == meta.id)
1034 .ok_or_else(|| not_found("no such CI run"))?;
1035 Ok(layout(
1036 &format!("{owner}/{repo}: CI #{}", run.id),
1037 user.as_ref(),
1038 html! {
1039 h1 {
1040 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1041 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1042 " · #" (run.id)
1043 }
1044 p {
1045 (status_badge(&run.status))
1046 " "
1047 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1048 " " span.muted { (run.ref_name) }
1049 }
1050 p.muted {
1051 "queued " (fmt_time(run.created_at))
1052 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1053 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1054 @if let Some(d) = run_duration(&run) { " · took " (d) }
1055 }
1056 @if run.log.is_empty() {
1057 p.muted { "No output yet." }
1058 } @else {
1059 pre.log { (run.log) }
1060 }
1061 },
1062 ))
1063}
1064
1065/// A coloured status pill for a CI run status string.
1066fn status_badge(status: &str) -> Markup {
1067 html! { span class=(format!("st {status}")) { (status) } }
1068}
1069
1070/// First 8 hex chars of a commit oid (for compact display).
1071fn short_commit(commit: &str) -> &str {
1072 &commit[..commit.len().min(8)]
1073}
1074
1075/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
1076fn run_duration(run: &CiRun) -> Option<String> {
1077 if run.started_at > 0 && run.finished_at >= run.started_at {
1078 Some(format!("{}s", run.finished_at - run.started_at))
1079 } else {
1080 None
1081 }
1082}
1083
1084/// Render one file's diff (added/deleted/modified) as a unified line diff.
1085fn render_file_diff(change: &FileChange) -> Markup {
1086 let (badge_cls, badge) = match change.kind {
1087 ChangeKind::Added => ("add", "added"),
1088 ChangeKind::Deleted => ("del", "deleted"),
1089 ChangeKind::Modified => ("mod", "modified"),
1090 };
1091 let binary = change.old.as_deref().is_some_and(is_binary)
1092 || change.new.as_deref().is_some_and(is_binary);
1093 html! {
1094 div.file-diff {
1095 div.head {
1096 span class=(format!("badge {badge_cls}")) { (badge) }
1097 " " (change.path)
1098 }
1099 @if binary {
1100 div.box { div.row { span.muted { "Binary file" } } }
1101 } @else {
1102 @let old = change.old.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
1103 @let new = change.new.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
1104 (unified_diff(&old, &new))
1105 }
1106 }
1107 }
1108}
1109
1110fn unified_diff(old: &str, new: &str) -> Markup {
1111 let diff = TextDiff::from_lines(old, new);
1112 html! {
1113 table.code.diff {
1114 @for change in diff.iter_all_changes() {
1115 @let (sign, cls) = match change.tag() {
1116 ChangeTag::Delete => ("-", "del"),
1117 ChangeTag::Insert => ("+", "ins"),
1118 ChangeTag::Equal => (" ", ""),
1119 };
1120 tr class=(cls) {
1121 td.sign { (sign) }
1122 td { (change.value().trim_end_matches('\n')) }
1123 }
1124 }
1125 }
1126 }
1127}
1128
1129/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
1130fn highlighter() -> &'static (SyntaxSet, Theme) {
1131 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
1132 HL.get_or_init(|| {
1133 let syntaxes = SyntaxSet::load_defaults_newlines();
1134 let themes = ThemeSet::load_defaults();
1135 let theme = themes
1136 .themes
1137 .get("InspiredGitHub")
1138 .or_else(|| themes.themes.values().next())
1139 .cloned()
1140 .expect("at least one default theme");
1141 (syntaxes, theme)
1142 })
1143}
1144
1145/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1146/// Falls back to escaped plain text for large files or on any failure.
1147fn highlight(path: &str, text: &str) -> Vec<String> {
1148 if text.len() > 512 * 1024 {
1149 return text.lines().map(escape).collect();
1150 }
1151 let (syntaxes, theme) = highlighter();
1152 let syntax = std::path::Path::new(path)
1153 .extension()
1154 .and_then(|e| e.to_str())
1155 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1156 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1157 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1158
1159 let mut h = HighlightLines::new(syntax, theme);
1160 text.lines()
1161 .map(|line| match h.highlight_line(line, syntaxes) {
1162 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1163 .unwrap_or_else(|_| escape(line)),
1164 Err(_) => escape(line),
1165 })
1166 .collect()
1167}
1168
1169fn escape(s: &str) -> String {
1170 s.replace('&', "&amp;")
1171 .replace('<', "&lt;")
1172 .replace('>', "&gt;")
1173}
1174
1175/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1176fn fmt_time(secs: i64) -> String {
1177 match OffsetDateTime::from_unix_timestamp(secs) {
1178 Ok(t) => format!(
1179 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1180 t.year(),
1181 u8::from(t.month()),
1182 t.day(),
1183 t.hour(),
1184 t.minute()
1185 ),
1186 Err(_) => secs.to_string(),
1187 }
1188}
1189
1190/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1191fn is_binary(bytes: &[u8]) -> bool {
1192 bytes.iter().take(8192).any(|&b| b == 0)
1193}