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::{
6 collections::{
7 BTreeMap,
8 HashMap,
9 },
10 path::PathBuf,
11 sync::{
12 Arc,
13 Mutex,
14 OnceLock,
15 },
16};
17
18use anvil_core::{
19 ApiToken,
20 App,
21 CiRun,
22 Repository,
23 SshKey,
24 User,
25 access,
26 api_tokens,
27 ci,
28 repos,
29 ssh_keys,
30 users,
31};
32use anvil_git::browse::{
33 self,
34 ChangeKind,
35 FileChange,
36};
37use axum::{
38 Form,
39 Router,
40 extract::{
41 Path,
42 Query,
43 State,
44 },
45 http::{
46 StatusCode,
47 header,
48 },
49 response::{
50 IntoResponse,
51 Redirect,
52 Response,
53 },
54 routing::{
55 get,
56 post,
57 },
58};
59use maud::{
60 DOCTYPE,
61 Markup,
62 PreEscaped,
63 html,
64};
65use similar::{
66 ChangeTag,
67 TextDiff,
68};
69use syntect::{
70 easy::HighlightLines,
71 highlighting::{
72 Theme,
73 ThemeSet,
74 },
75 html::{
76 IncludeBackground,
77 styled_line_to_highlighted_html,
78 },
79 parsing::SyntaxSet,
80};
81use time::OffsetDateTime;
82
83use crate::{
84 auth::{
85 CSRF_FIELD,
86 Csrf,
87 CurrentUser,
88 verify_csrf,
89 },
90 todomd,
91};
92
93const STYLE: &str = r#"
94:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; --success:#1a7f37; --success-bg:#dafbe1; --error:#cf222e; --error-bg:#ffebe9; --warning:#7d4e00; --warning-bg:#fff8c5; --info:#8250df; --info-bg:#fbefff; --dir-icon:#54aeff; --diff-ins-bg:#e6ffec; --diff-del-bg:#ffebe9; }
95@media (prefers-color-scheme: dark) {
96 :root { --fg:#e6edf3; --muted:#8b949e; --bg:#0d1117; --border:#30363d; --accent:#58a6ff; --code-bg:#161b22; --success:#3fb950; --success-bg:#1a3a1a; --error:#f85149; --error-bg:#3d1f1a; --warning:#d29922; --warning-bg:#3a2a1a; --info:#a371f7; --info-bg:#2a1e4e; --dir-icon:#79c0ff; --diff-ins-bg:#0d2818; --diff-del-bg:#2d1519; }
97}
98* { box-sizing:border-box; }
99body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
100a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
101header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
102.container { max-width:980px; margin:0 auto; padding:0 16px; }
103header.top .container { display:flex; align-items:center; gap:12px; }
104.brand { font-weight:700; font-size:16px; color:var(--fg); }
105main { padding:12px 0 24px; }
106h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
107.muted { color:var(--muted); }
108.repo-list { list-style:none; padding:0; margin:0; }
109.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
110.repo-list .name { font-size:16px; font-weight:600; }
111.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
112.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
113.box .row:first-child { border-top:0; }
114.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
115.box .row a.fc-msg { flex:1; margin-left:24px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; color:var(--muted); font-size:13px; }
116.box .row a.fc-msg:hover { color:var(--accent); }
117.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
118.icon { width:1em; height:1em; flex:none; fill:currentColor; color:var(--muted); vertical-align:-0.125em; }
119.icon.dir { color:var(--dir-icon); }
120.file-actions .btn .icon { color:inherit; }
121table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
122table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
123table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
124.cmds { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:12px 14px; margin:8px 0; font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-x:auto; }
125.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
126.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
127.clone-tabs { display:flex; margin-left:auto; }
128.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:0; margin-left:-1px; position:relative; background:var(--bg); color:var(--muted); cursor:pointer; }
129.clone-tab:first-child { border-radius:2em 0 0 2em; margin-left:0; }
130.clone-tab:last-child { border-radius:0 2em 2em 0; }
131.clone-tab:first-child:last-child { border-radius:2em; }
132.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); z-index:1; }
133.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
134.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
135.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
136.copy-btn:hover { color:var(--fg); }
137.copied-msg { display:none; color:var(--success); font-size:12px; }
138.clone.copied .copied-msg { display:inline; }
139.clone.copied .copy-btn { color:var(--success); }
140.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
141.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); }
142.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
143.view-toggle { margin:8px 0; }
144a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
145.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
146.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
147.md-body pre { background:var(--code-bg); border-radius:6px; padding:12px 14px; overflow-x:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
148.md-body code { background:var(--code-bg); border-radius:4px; padding:1px 4px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.9em; }
149.md-body pre code { background:none; padding:0; font-size:inherit; }
150.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
151.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
152.md-body img { max-width:100%; }
153.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
154.linkbtn:hover { text-decoration:underline; }
155.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; }
156.btn:hover { text-decoration:none; opacity:.92; }
157/* Repo header: title (+ visibility badge) on the left, quick-nav on the right;
158 wraps cleanly to its own line on narrow viewports instead of floating. */
159.repo-head { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:6px 16px; margin:24px 0 4px; }
160.repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; }
161.repo-title h1 { margin:0; }
162.repo-title .pill { font-size:11px; text-transform:uppercase; letter-spacing:.04em; align-self:center; }
163.repo-nav { font-size:13px; display:flex; align-items:baseline; gap:8px; color:var(--muted); }
164.repo-nav a { color:var(--muted); }
165.repo-nav a:hover { color:var(--accent); text-decoration:none; }
166.repo-nav .sep { color:var(--border); }
167.repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; }
168.repo-meta b { font-weight:600; color:var(--fg); }
169.pill-group { display:inline-flex; }
170.pill-group > .pill { border-radius:0; margin-left:-1px; position:relative; }
171.pill-group > .pill:first-child { border-radius:2em 0 0 2em; margin-left:0; }
172.pill-group > .pill:last-child { border-radius:0 2em 2em 0; }
173form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
174form.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; }
175form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
176form.stack select { padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
177form.stack textarea.editor { max-width:none; font:13px/1.5 ui-monospace,monospace; tab-size:4; resize:vertical; }
178p.file-actions { margin:10px 0; display:flex; gap:6px; align-items:center; }
179.file-actions .btn { padding:3px 11px; font-size:12px; font-weight:500; border-radius:6px; display:inline-flex; align-items:center; gap:5px; }
180table.usage { border-collapse:collapse; width:100%; max-width:680px; margin-top:12px; }
181table.usage th, table.usage td { padding:6px 10px; border-bottom:1px solid var(--border); text-align:left; }
182table.usage .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
183table.usage tfoot td { font-weight:600; border-top:2px solid var(--border); border-bottom:none; }
184.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
185.issue-dot.open { background:var(--success); }
186.issue-dot.closed { background:var(--info); }
187.st.issue-open { background:var(--success-bg); color:var(--success); }
188.st.issue-closed { background:var(--info-bg); color:var(--info); }
189.issue-post { margin:12px 0; }
190.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
191.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
192.readme { margin-top:16px; }
193.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
194/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
195 nested frames. */
196.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
197.kanban .col { flex:1 1 0; min-width:240px; }
198.kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); }
199.kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
200.kanban .card { position:relative; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
201.kanban .card-del { position:absolute; top:3px; right:4px; margin:0; }
202.kanban .card-del-btn { border:0; background:none; color:var(--muted); cursor:pointer; font-size:16px; line-height:1; padding:1px 5px; border-radius:4px; opacity:0; transition:opacity .1s,background .1s; }
203.kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; }
204.kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); }
205.kanban .card .title { padding-right:14px; }
206.kanban .card:has(.card-grip) { padding-left:28px; }
207.kanban .card-grip { position:absolute; left:2px; top:5px; color:var(--muted); cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; line-height:0; padding:4px 5px; border-radius:4px; }
208/* The touch must land on the grip (touch-action:none), not the icon inside it,
209 or the browser claims the gesture for scrolling and never drags. */
210.kanban .card-grip svg { pointer-events:none; }
211.kanban .card-grip:hover { color:var(--fg); background:var(--code-bg); }
212.kanban .card.dragging { opacity:.4; pointer-events:none; }
213.kanban .card.dragging .card-grip { pointer-events:auto; cursor:grabbing; }
214.kanban .card .title p { margin:0; font-weight:500; }
215.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
216.kanban .card details { margin-top:7px; }
217.kanban .card summary { cursor:pointer; font-size:11px; font-weight:500; letter-spacing:.03em; text-transform:uppercase; color:var(--muted); list-style:none; display:inline-flex; align-items:center; gap:5px; user-select:none; }
218.kanban .card summary:hover { color:var(--accent); }
219.kanban .card summary::-webkit-details-marker { display:none; }
220.kanban .card summary::before { content:"\25B8"; font-size:9px; transition:transform .15s ease; }
221.kanban .card details[open] summary { margin-bottom:5px; }
222.kanban .card details[open] summary::before { transform:rotate(90deg); }
223.kanban .card .card-details { font-size:13px; color:var(--fg); line-height:1.5; }
224.kanban .card .card-details p { margin:0 0 6px; }
225.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
226.kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
227.kanban .card .card-details > :last-child { margin-bottom:0; }
228.kanban .card .title img { max-width:100%; height:auto; border-radius:4px; }
229/* Repo secrets (docs/secrets.md): sealed values, plus the CI unlock banner. */
230.secret-unlocked { background:var(--success-bg); color:var(--success); border-radius:6px; padding:8px 12px; font-size:13px; }
231.secret-warn { background:var(--warning-bg); color:var(--warning); border-radius:6px; padding:8px 12px; font-size:13px; }
232.secret-stale { margin-left:10px; font-size:12px; color:var(--warning); }
233#secrets-form textarea { width:100%; font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
234.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
235/* Repo home: the board leads the page, clipped to a fixed-height teaser that
236 expands in place. A checkbox drives it, not <details>, because a closed
237 <details> hides its content outright — there'd be no preview to clip.
238 `.fits` is set by script when the board is short enough to need neither. */
239.todo-preview { margin:4px 0 16px; }
240.todo-preview .todo-board-head { margin-top:0; }
241.todo-expand { position:absolute; width:1px; height:1px; opacity:0; }
242.todo-preview-body { position:relative; max-height:300px; overflow:hidden; }
243.todo-preview-body::after { content:""; position:absolute; left:0; right:0; bottom:0; height:80px; background:linear-gradient(to bottom, transparent, var(--bg)); pointer-events:none; }
244.todo-expand:checked ~ .todo-preview-body { max-height:none; }
245.todo-expand:checked ~ .todo-preview-body::after { display:none; }
246.todo-more { display:inline-block; cursor:pointer; font-size:12px; color:var(--muted); padding:2px 0 6px; user-select:none; }
247.todo-more:hover { color:var(--accent); }
248.todo-more::after { content:"Show more \25BE"; }
249.todo-expand:checked ~ .todo-more::after { content:"Show less \25B4"; }
250.todo-expand:focus-visible ~ .todo-more { outline:2px solid var(--accent); outline-offset:2px; border-radius:3px; }
251.todo-preview.fits .todo-preview-body { max-height:none; }
252.todo-preview.fits .todo-preview-body::after { display:none; }
253.todo-preview.fits .todo-more { display:none; }
254.todo-notes { margin:8px 2px; }
255.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
256.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; }
257.latest-commit + .box { border-radius:0 0 6px 6px; }
258.commit-list { list-style:none; padding:0; margin:0; }
259.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
260.commit-list li:first-child { border-top:0; }
261.sha { font:12px ui-monospace,monospace; color:var(--muted); }
262.file-diff { margin:16px 0; }
263.file-diff summary.head { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 12px; font:12px ui-monospace,monospace; cursor:pointer; display:flex; align-items:center; gap:8px; list-style:none; }
264.file-diff summary.head::-webkit-details-marker { display:none; }
265.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
266.file-diff[open] summary.head::before { content:"\25BE"; }
267.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
268.file-diff .stat { margin-left:auto; white-space:nowrap; }
269.stat .plus { color:var(--success); } .stat .minus { color:var(--error); }
270table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
271table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
272table.diff tr.ins { background:var(--diff-ins-bg); } table.diff tr.ins td.sign { color:var(--success); }
273table.diff tr.del { background:var(--diff-del-bg); } table.diff tr.del td.sign { color:var(--error); }
274table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
275.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
276.badge.add { background:var(--success-bg); color:var(--success); } .badge.del { background:var(--error-bg); color:var(--error); } .badge.mod { background:var(--warning-bg); color:var(--warning); }
277.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
278.st.queued { background:var(--code-bg); color:var(--muted); } .st.running { background:var(--warning-bg); color:var(--warning); }
279.st.success { background:var(--success-bg); color:var(--success); } .st.failure, .st.error { background:var(--error-bg); color:var(--error); }
280.log { background:var(--code-bg); color:var(--fg); 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; border:1px solid var(--border); }
281@media (prefers-color-scheme: dark) {
282 .log { background:#0d1117; color:#e6edf3; border:0; }
283}
284footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
285details.nav-menu { position:relative; }
286details.nav-menu > summary { list-style:none; cursor:pointer; color:var(--accent); font-size:14px; }
287details.nav-menu > summary::-webkit-details-marker { display:none; }
288details.nav-menu > summary::after { content:""; display:inline-block; width:0; height:0; margin-left:6px; vertical-align:middle; border:4px solid transparent; border-top:5px solid var(--muted); border-bottom:0; transition:transform .15s ease; }
289details.nav-menu > summary:hover::after { border-top-color:var(--accent); }
290details.nav-menu[open] > summary::after { transform:rotate(180deg); }
291.nav-dropdown { position:absolute; right:0; top:calc(100% + 6px); background:var(--bg); border:1px solid var(--border); border-radius:6px; min-width:130px; box-shadow:0 4px 14px rgba(0,0,0,.1); z-index:200; padding:4px 0; }
292.nav-dropdown a, .nav-dropdown button { display:block; width:100%; padding:6px 14px; font-size:13px; color:var(--fg); text-align:left; background:none; border:0; cursor:pointer; font:inherit; text-decoration:none; }
293.nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); }
294.nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; }
295.nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
296.nav-dropdown a.current { font-weight:600; }
297details.rev-menu { display:inline-block; }
298details.rev-menu > summary .pill { cursor:pointer; }
299@media (max-width:720px) {
300 .kanban { flex-direction:column; gap:14px; overflow-x:visible; }
301 .kanban .col { min-width:0; width:100%; }
302}
303"#;
304
305/// Icon set as an SVG sprite (a hidden `<svg>` of `<symbol id="i-…">`s),
306/// authored in `assets/icons.svg` and embedded at compile time. The layout
307/// emits it once per page; [`icon`] references a symbol via `<use>`, so the
308/// path data is never duplicated in the rendered HTML.
309const ICON_SPRITE: &str = include_str!("../assets/icons.svg");
310
311/// The icons defined in the sprite. Each maps to a `<symbol id="i-…">` in
312/// `assets/icons.svg` — keep the two in sync.
313#[derive(Clone, Copy)]
314pub(crate) enum Icon {
315 Clipboard,
316 Pencil,
317 Plus,
318 Folder,
319 File,
320 Grip,
321}
322
323impl Icon {
324 /// The sprite symbol id (`<symbol id="…">`).
325 fn id(self) -> &'static str {
326 match self {
327 Icon::Clipboard => "i-clipboard",
328 Icon::Pencil => "i-pencil",
329 Icon::Plus => "i-plus",
330 Icon::Folder => "i-folder",
331 Icon::File => "i-file",
332 Icon::Grip => "i-grip",
333 }
334 }
335}
336
337/// Reference a sprite symbol as an inline `<svg>`, sized/colored by the `.icon`
338/// CSS (1em, `currentColor`).
339pub(crate) fn icon(i: Icon) -> Markup {
340 icon_with(i, "icon")
341}
342
343/// Like [`icon`] but with custom classes (e.g. `"icon dir"` to tint a folder).
344fn icon_with(i: Icon, class: &str) -> Markup {
345 PreEscaped(format!(
346 r##"<svg class="{class}" aria-hidden="true"><use href="#{}"></use></svg>"##,
347 i.id()
348 ))
349}
350
351/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
352/// Registered once on `document`, so it survives htmx body swaps.
353/// Make htmx render error responses instead of discarding them.
354///
355/// Handlers answer a rejected form with the page *and* the reason — a bad
356/// password, an unparseable ssh key — under a 4xx status. htmx's default
357/// `responseHandling` swaps only 2xx, so with `hx-boost` on the body every one
358/// of those pages was silently dropped and the button looked broken. Without
359/// JavaScript the same responses always rendered fine, which is why this hid.
360const HTMX_CONFIG_JS: &str = r#"
361htmx.config.responseHandling = [
362 { code: "204", swap: false },
363 { code: "[23]..", swap: true },
364 { code: "[45]..", swap: true, error: true },
365];
366"#;
367
368const CLONE_JS: &str = r#"
369(function(){
370 function copyText(t){
371 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
372 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
373 document.body.appendChild(ta); ta.focus(); ta.select();
374 try{document.execCommand('copy')}catch(e){}
375 document.body.removeChild(ta); return Promise.resolve();
376 }
377 document.addEventListener('click', function(e){
378 var nm=e.target.closest('details.nav-menu');
379 document.querySelectorAll('details.nav-menu').forEach(function(d){ if(d!==nm) d.removeAttribute('open'); });
380 var tab=e.target.closest('.clone-tab');
381 if(tab){
382 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
383 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
384 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
385 return;
386 }
387 var copy=e.target.closest('.copy-btn');
388 if(copy){
389 var box=copy.closest('.clone');
390 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
391 box.classList.add('copied');
392 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
393 });
394 }
395 });
396})();
397"#;
398
399/// Mount the web UI routes.
400pub fn routes(router: Router<App>) -> Router<App> {
401 router
402 .route("/", get(home))
403 .route("/-/settings", get(account_settings))
404 .route("/-/settings/keys", post(add_ssh_key))
405 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
406 .route("/-/settings/tokens", post(create_token))
407 .route("/-/settings/tokens/{id}/delete", post(revoke_token))
408 .route("/-/new", get(new_repo_form).post(new_repo_submit))
409 .route("/{username}", get(user_profile))
410 .route(
411 "/{owner}/{repo}/settings",
412 get(repo_settings).post(repo_settings_submit),
413 )
414 .route("/{owner}/{repo}", get(repo_index))
415 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
416 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
417 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
418 .route(
419 "/{owner}/{repo}/edit/{rev}/{*path}",
420 get(edit_form).post(edit_submit),
421 )
422 .route(
423 "/{owner}/{repo}/add-task/{rev}/{*path}",
424 get(add_task_form).post(add_task_submit),
425 )
426 .route(
427 "/{owner}/{repo}/delete-task/{rev}/{*path}",
428 post(delete_task),
429 )
430 .route("/{owner}/{repo}/move-task/{rev}/{*path}", post(move_task))
431 .route("/{owner}/{repo}/commits/{rev}", get(commits))
432 .route("/{owner}/{repo}/commit/{id}", get(commit))
433 .route("/{owner}/{repo}/ci", get(ci_runs))
434 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
435 .route("/-/static/htmx.min.js", get(htmx_js))
436}
437
438/// Serve the vendored htmx script (embedded in the binary).
439async fn htmx_js() -> Response {
440 (
441 [(
442 header::CONTENT_TYPE,
443 "application/javascript; charset=utf-8",
444 )],
445 include_str!("../assets/htmx.min.js"),
446 )
447 .into_response()
448}
449
450pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
451 // Attach the session's CSRF token to every htmx request as a header, so any
452 // JS-driven action carries it without a hidden field. Omitted (no attribute)
453 // when unauthenticated. The token is hex, so it needs no JSON escaping.
454 let csrf = crate::auth::current_csrf();
455 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
456 html! {
457 (DOCTYPE)
458 html lang="en" {
459 head {
460 meta charset="utf-8";
461 meta name="viewport" content="width=device-width, initial-scale=1";
462 title { (title) " · anvil" }
463 style { (PreEscaped(STYLE)) }
464 }
465 body hx-boost="true" hx-headers=[hx_headers] {
466 (PreEscaped(ICON_SPRITE))
467 header.top { div.container {
468 a.brand href="/" { "anvil" }
469 span style="margin-left:auto" {
470 @match user {
471 Some(u) => {
472 details.nav-menu {
473 summary { (u.username) }
474 div.nav-dropdown {
475 a href="/-/settings" { "Settings" }
476 @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } }
477 form method="post" action="/-/logout" {
478 button type="submit" { "Sign out" }
479 }
480 }
481 }
482 }
483 None => { a href="/-/login" { "sign in" } }
484 }
485 }
486 } }
487 main { div.container { (body) } }
488 footer { div.container { "anvil — a git forge" } }
489 script src="/-/static/htmx.min.js" {}
490 script { (PreEscaped(HTMX_CONFIG_JS)) }
491 script { (PreEscaped(CLONE_JS)) }
492 }
493 }
494 }
495}
496
497/// Hidden CSRF token field for embedding inside a mutating `<form>`.
498pub(crate) fn csrf_input(token: &str) -> Markup {
499 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
500}
501
502pub(crate) fn not_found(message: &str) -> Response {
503 (
504 StatusCode::NOT_FOUND,
505 layout(
506 "Not found",
507 None,
508 html! { h1 { "Not found" } p.muted { (message) } },
509 ),
510 )
511 .into_response()
512}
513
514pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
515 tracing::error!("ui error: {err}");
516 (
517 StatusCode::INTERNAL_SERVER_ERROR,
518 layout("Error", None, html! { h1 { "Something went wrong" } }),
519 )
520 .into_response()
521}
522
523/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
524/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
525pub(crate) async fn resolve_repo(
526 app: &App,
527 viewer: Option<&User>,
528 owner: &str,
529 name: &str,
530) -> Result<(PathBuf, Repository), Response> {
531 let owner_user = users::find_by_username(&app.db, owner)
532 .await
533 .map_err(server_error)?
534 .ok_or_else(|| not_found("no such user"))?;
535 let repo = repos::find(&app.db, owner_user.id, name)
536 .await
537 .map_err(server_error)?
538 .ok_or_else(|| not_found("no such repository"))?;
539 if !access::can_read(&repo, viewer) {
540 return Err(not_found("no such repository"));
541 }
542 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
543 if !path.exists() {
544 return Err(not_found("repository not found on disk"));
545 }
546 Ok((path, repo))
547}
548
549/// `GET /` — list repositories visible to the current user.
550async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
551 let all = repos::list_all_with_owner(&app.db)
552 .await
553 .map_err(server_error)?;
554 let repos: Vec<_> = all
555 .into_iter()
556 .filter(|r| {
557 !r.is_private
558 || user
559 .as_ref()
560 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
561 })
562 .collect();
563 Ok(layout(
564 "Repositories",
565 user.as_ref(),
566 html! {
567 div style="display:flex;align-items:center" {
568 h1 style="margin-right:auto" { "Repositories" }
569 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
570 }
571 @if repos.is_empty() {
572 p.muted {
573 "No repositories yet. "
574 @if user.is_some() { a href="/-/new" { "Create one" } "." }
575 @else { "Sign in to create one." }
576 }
577 } @else {
578 ul.repo-list {
579 @for r in &repos {
580 @let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), &r.owner, &r.name);
581 @let updated = browse::last_commit_time(&path).ok().flatten();
582 li {
583 div.name {
584 a href=(format!("/{}", r.owner)) { (r.owner) }
585 "/"
586 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
587 @if r.is_private { " " span.pill { "private" } }
588 @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
589 }
590 @if !r.description.is_empty() { div.muted { (r.description) } }
591 @if let Some(t) = updated {
592 div.muted { "Updated " span title=(fmt_time(t)) { (fmt_relative(t)) } }
593 }
594 }
595 }
596 }
597 }
598 },
599 ))
600}
601
602/// `GET /{username}` — a user's profile: their repositories (public to all;
603/// private only to themselves or an admin).
604async fn user_profile(
605 State(app): State<App>,
606 CurrentUser(viewer): CurrentUser,
607 Path(username): Path<String>,
608) -> Result<Markup, Response> {
609 let owner = users::find_by_username(&app.db, &username)
610 .await
611 .map_err(server_error)?
612 .ok_or_else(|| not_found("no such user"))?;
613 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
614 .await
615 .map_err(server_error)?
616 .into_iter()
617 .filter(|r| access::can_read(r, viewer.as_ref()))
618 .collect();
619 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
620
621 Ok(layout(
622 &owner.username,
623 viewer.as_ref(),
624 html! {
625 div style="display:flex;align-items:center" {
626 h1 style="margin-right:auto" { (owner.username) }
627 @if is_self { a.btn href="/-/new" { "New repository" } }
628 }
629 h2 { "Repositories" }
630 @if visible.is_empty() {
631 p.muted { "No repositories." }
632 } @else {
633 ul.repo-list {
634 @for r in &visible {
635 @let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), &owner.username, &r.name);
636 @let updated = browse::last_commit_time(&path).ok().flatten();
637 li {
638 div.name {
639 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
640 @if r.is_private { " " span.pill { "private" } }
641 @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
642 }
643 @if !r.description.is_empty() { div.muted { (r.description) } }
644 @if let Some(t) = updated {
645 div.muted { "Updated " span title=(fmt_time(t)) { (fmt_relative(t)) } }
646 }
647 }
648 }
649 }
650 }
651 },
652 ))
653}
654
655#[derive(serde::Deserialize)]
656struct AddKeyForm {
657 #[serde(default)]
658 title: String,
659 key: String,
660 #[serde(default)]
661 csrf: String,
662}
663
664/// `GET /settings` — account settings: profile + SSH keys.
665async fn account_settings(
666 State(app): State<App>,
667 CurrentUser(user): CurrentUser,
668 csrf: Csrf,
669) -> Response {
670 let Some(user) = user else {
671 return Redirect::to("/-/login").into_response();
672 };
673 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
674 Ok(keys) => keys,
675 Err(e) => return server_error(e),
676 };
677 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
678 let passkeys = anvil_core::passkeys::list(&app.db, user.id)
679 .await
680 .unwrap_or_default();
681 account_page(&user, &keys, &tokens, &passkeys, None, None, &csrf.0).into_response()
682}
683
684/// `POST /settings/keys` — register an SSH public key for the current user.
685async fn add_ssh_key(
686 State(app): State<App>,
687 CurrentUser(user): CurrentUser,
688 csrf: Csrf,
689 Form(form): Form<AddKeyForm>,
690) -> Response {
691 let Some(user) = user else {
692 return Redirect::to("/-/login").into_response();
693 };
694 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
695 return resp;
696 }
697 let result = match ssh_keys::parse_public_key(&form.key) {
698 Ok((fingerprint, content)) => {
699 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
700 .await
701 .map(|_| ())
702 }
703 Err(e) => Err(e),
704 };
705 match result {
706 Ok(()) => Redirect::to("/-/settings").into_response(),
707 Err(e) => {
708 let keys = ssh_keys::list_by_user(&app.db, user.id)
709 .await
710 .unwrap_or_default();
711 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
712 let passkeys = anvil_core::passkeys::list(&app.db, user.id)
713 .await
714 .unwrap_or_default();
715 (
716 StatusCode::BAD_REQUEST,
717 account_page(
718 &user,
719 &keys,
720 &tokens,
721 &passkeys,
722 None,
723 Some(&e.to_string()),
724 &csrf.0,
725 ),
726 )
727 .into_response()
728 }
729 }
730}
731
732#[derive(serde::Deserialize)]
733struct CreateTokenForm {
734 #[serde(default)]
735 name: String,
736 #[serde(default)]
737 csrf: String,
738}
739
740/// `POST /settings/tokens` — mint a read-only PAT for the current user and show
741/// the plaintext once (it's only stored hashed, so it can't be shown again).
742async fn create_token(
743 State(app): State<App>,
744 CurrentUser(user): CurrentUser,
745 csrf: Csrf,
746 Form(form): Form<CreateTokenForm>,
747) -> Response {
748 let Some(user) = user else {
749 return Redirect::to("/-/login").into_response();
750 };
751 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
752 return resp;
753 }
754 let name = match form.name.trim() {
755 "" => "api",
756 n => n,
757 };
758 let plaintext = match api_tokens::create(&app.db, user.id, name, api_tokens::READ).await {
759 Ok((_, plaintext)) => plaintext,
760 Err(e) => return server_error(e),
761 };
762 let keys = ssh_keys::list_by_user(&app.db, user.id)
763 .await
764 .unwrap_or_default();
765 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
766 let passkeys = anvil_core::passkeys::list(&app.db, user.id)
767 .await
768 .unwrap_or_default();
769 account_page(
770 &user,
771 &keys,
772 &tokens,
773 &passkeys,
774 Some(&plaintext),
775 None,
776 &csrf.0,
777 )
778 .into_response()
779}
780
781/// `POST /settings/tokens/{id}/delete` — revoke one of the current user's
782/// tokens (ownership enforced: a user can only revoke their own).
783async fn revoke_token(
784 State(app): State<App>,
785 CurrentUser(user): CurrentUser,
786 csrf: Csrf,
787 Path(id): Path<i64>,
788 Form(form): Form<crate::auth::CsrfForm>,
789) -> Response {
790 let Some(user) = user else {
791 return Redirect::to("/-/login").into_response();
792 };
793 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
794 return resp;
795 }
796 let owned = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
797 if owned.iter().any(|t| t.id == id)
798 && let Err(e) = api_tokens::revoke(&app.db, id).await
799 {
800 return server_error(e);
801 }
802 Redirect::to("/-/settings").into_response()
803}
804
805/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
806async fn delete_ssh_key(
807 State(app): State<App>,
808 CurrentUser(user): CurrentUser,
809 csrf: Csrf,
810 Path(id): Path<i64>,
811 Form(form): Form<crate::auth::CsrfForm>,
812) -> Response {
813 let Some(user) = user else {
814 return Redirect::to("/-/login").into_response();
815 };
816 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
817 return resp;
818 }
819 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
820 return server_error(e);
821 }
822 Redirect::to("/-/settings").into_response()
823}
824
825#[allow(clippy::too_many_arguments)]
826fn account_page(
827 user: &User,
828 keys: &[SshKey],
829 tokens: &[ApiToken],
830 passkeys: &[anvil_core::Passkey],
831 new_token: Option<&str>,
832 error: Option<&str>,
833 csrf: &str,
834) -> Markup {
835 layout(
836 "Account settings",
837 Some(user),
838 html! {
839 h1 { "Account settings" }
840 p.muted {
841 "Signed in as " strong { (user.username) }
842 @if !user.email.is_empty() { " · " (user.email) }
843 }
844
845 h2 { "SSH keys" }
846 p.muted { "Add a public key to clone and push over SSH." }
847 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
848 @if keys.is_empty() {
849 p.muted { "No SSH keys yet." }
850 } @else {
851 div.box {
852 @for k in keys {
853 div.row {
854 div {
855 @if !k.title.is_empty() { strong { (k.title) } " " }
856 span.sha { (k.fingerprint) }
857 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
858 }
859 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
860 (csrf_input(csrf))
861 button.linkbtn type="submit" { "delete" }
862 }
863 }
864 }
865 }
866 }
867
868 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
869 (csrf_input(csrf))
870 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
871 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
872 p { button.btn type="submit" { "Add SSH key" } }
873 }
874
875 h2 style="margin-top:28px" { "Personal access tokens" }
876 p.muted { "Read-only API tokens for tooling (e.g. fetching attachments over HTTP). The secret is shown once, at creation." }
877 @if let Some(token) = new_token {
878 div.box style="border-color:var(--accent)" {
879 p style="margin-top:0" { strong { "New token — copy it now; it won't be shown again." } }
880 pre.cmds { (token) }
881 }
882 }
883 @if tokens.is_empty() {
884 p.muted { "No tokens yet." }
885 } @else {
886 div.box {
887 @for t in tokens {
888 div.row {
889 div {
890 strong { (t.name) } " " span.pill { (t.scopes) }
891 div.muted style="font-size:12px" { "added " (fmt_time(t.created_at)) }
892 }
893 form method="post" action=(format!("/-/settings/tokens/{}/delete", t.id)) {
894 (csrf_input(csrf))
895 button.linkbtn type="submit" { "revoke" }
896 }
897 }
898 }
899 }
900 }
901 form.stack method="post" action="/-/settings/tokens" style="margin-top:16px" {
902 (csrf_input(csrf))
903 p { label { "Name" br; input type="text" name="name" placeholder="claude"; } }
904 p { button.btn type="submit" { "Create token" } }
905 }
906 (crate::passkeys::shared_script())
907 (crate::passkeys::settings_section(user, passkeys, csrf))
908 },
909 )
910}
911
912pub(crate) fn forbidden() -> Response {
913 (
914 StatusCode::FORBIDDEN,
915 layout(
916 "Forbidden",
917 None,
918 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
919 ),
920 )
921 .into_response()
922}
923
924#[derive(serde::Deserialize)]
925struct NewRepoForm {
926 name: String,
927 #[serde(default)]
928 description: String,
929 private: Option<String>,
930 #[serde(default)]
931 csrf: String,
932}
933
934#[derive(serde::Deserialize)]
935struct SettingsForm {
936 #[serde(default)]
937 description: String,
938 private: Option<String>,
939 #[serde(default)]
940 mirror_url: String,
941 #[serde(default)]
942 csrf: String,
943}
944
945/// `GET /new` — new-repository form (requires login).
946async fn new_repo_form(
947 State(app): State<App>,
948 CurrentUser(user): CurrentUser,
949 csrf: Csrf,
950) -> Response {
951 let Some(user) = user else {
952 return Redirect::to("/-/login").into_response();
953 };
954 let remote = push_remote_url(&app, &user.username, "");
955 new_repo_page(&user, None, "", "", false, &remote, &csrf.0).into_response()
956}
957
958/// The remote URL to suggest for push-to-create: SSH when enabled (pushes
959/// without a credential prompt), otherwise HTTP. `name` may be empty, in which
960/// case a `<name>` placeholder is used.
961fn push_remote_url(app: &App, owner: &str, name: &str) -> String {
962 let name = if name.is_empty() { "<name>" } else { name };
963 if app.config.ssh.enabled {
964 app.config.ssh_clone_url(owner, name)
965 } else {
966 app.config.http_clone_url(owner, name)
967 }
968}
969
970/// `POST /new` — create a repository owned by the current user.
971async fn new_repo_submit(
972 State(app): State<App>,
973 CurrentUser(user): CurrentUser,
974 csrf: Csrf,
975 Form(form): Form<NewRepoForm>,
976) -> Response {
977 let Some(user) = user else {
978 return Redirect::to("/-/login").into_response();
979 };
980 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
981 return resp;
982 }
983 let private = form.private.is_some();
984 match repos::create(
985 &app.db,
986 &app.config.repositories_dir(),
987 &user,
988 &form.name,
989 &form.description,
990 private,
991 )
992 .await
993 {
994 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
995 Err(e) => {
996 let remote = push_remote_url(&app, &user.username, &form.name);
997 (
998 StatusCode::BAD_REQUEST,
999 new_repo_page(
1000 &user,
1001 Some(&e.to_string()),
1002 &form.name,
1003 &form.description,
1004 private,
1005 &remote,
1006 &csrf.0,
1007 ),
1008 )
1009 .into_response()
1010 }
1011 }
1012}
1013
1014fn new_repo_page(
1015 user: &User,
1016 error: Option<&str>,
1017 name: &str,
1018 description: &str,
1019 private: bool,
1020 remote: &str,
1021 csrf: &str,
1022) -> Markup {
1023 layout(
1024 "New repository",
1025 Some(user),
1026 html! {
1027 h1 { "New repository" }
1028 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1029 form.stack method="post" action="/-/new" {
1030 (csrf_input(csrf))
1031 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
1032 p { label { "Description" br; input type="text" name="description" value=(description); } }
1033 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
1034 p { button.btn type="submit" { "Create repository" } }
1035 }
1036 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
1037
1038 h2 { "…or push an existing repository" }
1039 p.muted { "Pushing to a name that doesn't exist yet creates the repository (private). No need for the form above." }
1040 pre.cmds { (format!("git remote add origin {remote}\ngit push -u origin main")) }
1041 },
1042 )
1043}
1044
1045/// Load a repo for an owner-only settings action, enforcing write access.
1046async fn resolve_for_settings(
1047 app: &App,
1048 viewer: Option<&User>,
1049 owner: &str,
1050 name: &str,
1051) -> Result<Repository, Response> {
1052 let owner_user = users::find_by_username(&app.db, owner)
1053 .await
1054 .map_err(server_error)?
1055 .ok_or_else(|| not_found("no such repository"))?;
1056 let repo = repos::find(&app.db, owner_user.id, name)
1057 .await
1058 .map_err(server_error)?
1059 .ok_or_else(|| not_found("no such repository"))?;
1060 if !access::can_read(&repo, viewer) {
1061 return Err(not_found("no such repository"));
1062 }
1063 if !access::can_write(&repo, viewer) {
1064 return Err(forbidden());
1065 }
1066 Ok(repo)
1067}
1068
1069/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
1070async fn repo_settings(
1071 State(app): State<App>,
1072 CurrentUser(user): CurrentUser,
1073 csrf: Csrf,
1074 Path((owner, repo)): Path<(String, String)>,
1075) -> Response {
1076 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
1077 Ok(m) => m,
1078 Err(resp) => return resp,
1079 };
1080 let secrets = crate::secrets::settings_section(&app, &owner, &repo, &meta).await;
1081 settings_page(user.as_ref(), &owner, &repo, &meta, secrets, None, &csrf.0).into_response()
1082}
1083
1084/// `POST /{owner}/{repo}/settings` — update description / visibility.
1085async fn repo_settings_submit(
1086 State(app): State<App>,
1087 CurrentUser(user): CurrentUser,
1088 csrf: Csrf,
1089 Path((owner, repo)): Path<(String, String)>,
1090 Form(form): Form<SettingsForm>,
1091) -> Response {
1092 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
1093 Ok(m) => m,
1094 Err(resp) => return resp,
1095 };
1096 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1097 return resp;
1098 }
1099 if let Err(e) = repos::update_settings(
1100 &app.db,
1101 meta.id,
1102 &form.description,
1103 form.private.is_some(),
1104 &form.mirror_url,
1105 )
1106 .await
1107 {
1108 return server_error(e);
1109 }
1110 Redirect::to(&format!("/{owner}/{repo}")).into_response()
1111}
1112
1113fn settings_page(
1114 user: Option<&User>,
1115 owner: &str,
1116 repo: &str,
1117 meta: &Repository,
1118 secrets: Markup,
1119 error: Option<&str>,
1120 csrf: &str,
1121) -> Markup {
1122 layout(
1123 &format!("{owner}/{repo}: settings"),
1124 user,
1125 html! {
1126 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
1127 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1128 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
1129 (csrf_input(csrf))
1130 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
1131 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
1132 p {
1133 label {
1134 "Mirror push URL" br;
1135 input type="text" name="mirror_url" value=(meta.mirror_url)
1136 placeholder="https://x-access-token:<token>@github.com/you/repo.git";
1137 }
1138 br;
1139 span.muted style="font-size:12px" {
1140 "After every push here, all refs are mirrored to this remote ("
1141 code { "git push --mirror" }
1142 "). Stored as-is — use a scoped token. Empty disables it."
1143 }
1144 }
1145 p { button.btn type="submit" { "Save changes" } }
1146 }
1147 (secrets)
1148 },
1149 )
1150}
1151
1152fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
1153 let http = app.config.http_clone_url(owner, name);
1154 let ssh = app
1155 .config
1156 .ssh
1157 .enabled
1158 .then(|| app.config.ssh_clone_url(owner, name));
1159 // SSH first and preselected when available — it's the protocol that can
1160 // push without a credential prompt.
1161 let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
1162 html! {
1163 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
1164 div.clone-head {
1165 span.muted { "Clone" }
1166 div.clone-tabs {
1167 @if ssh.is_some() {
1168 button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
1169 button.clone-tab type="button" data-proto="http" { "HTTP" }
1170 } @else {
1171 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
1172 }
1173 }
1174 }
1175 div.clone-cmd {
1176 code { (default_cmd) }
1177 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
1178 (icon(Icon::Clipboard))
1179 }
1180 span.copied-msg { "Copied!" }
1181 }
1182 }
1183 }
1184}
1185
1186/// `GET /{owner}/{repo}` — repository overview with the root tree.
1187async fn repo_index(
1188 State(app): State<App>,
1189 CurrentUser(user): CurrentUser,
1190 Path((owner, repo)): Path<(String, String)>,
1191) -> Result<Markup, Response> {
1192 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1193 let overview = browse::overview(&path).map_err(server_error)?;
1194
1195 let can_write = access::can_write(&meta, user.as_ref());
1196 let header = html! {
1197 div.repo-head {
1198 span.repo-title {
1199 h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) }
1200 @if meta.is_private { span.pill { "private" } }
1201 }
1202 nav.repo-nav {
1203 a href=(format!("/{owner}/{repo}/blob/{}/TODO.md", enc_ref(overview.default_branch.as_deref().unwrap_or("main")))) { "Todo" }
1204 span.sep { "·" }
1205 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1206 span.sep { "·" }
1207 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
1208 @if can_write {
1209 span.sep { "·" }
1210 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
1211 }
1212 }
1213 }
1214 @if !meta.description.is_empty() { p.muted { (meta.description) } }
1215 p.repo-meta {
1216 span { b { (overview.branches.len()) } " " (plural(overview.branches.len(), "branch", "branches")) }
1217 span { b { (overview.tags.len()) } " " (plural(overview.tags.len(), "tag", "tags")) }
1218 }
1219 (clone_box(&app, &owner, &repo))
1220 };
1221
1222 if overview.is_empty {
1223 return Ok(layout(
1224 &format!("{owner}/{repo}"),
1225 user.as_ref(),
1226 html! {
1227 (header)
1228 p.muted { "This repository is empty. Push to it to get started." }
1229 },
1230 ));
1231 }
1232
1233 let rev = overview
1234 .default_branch
1235 .clone()
1236 .unwrap_or_else(|| "HEAD".to_string());
1237 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
1238 let latest = browse::commit_log(&path, &rev, 1)
1239 .map_err(server_error)?
1240 .into_iter()
1241 .next();
1242 // Best-effort: a failed walk only costs the per-entry annotations.
1243 let entry_commits =
1244 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
1245
1246 // A root README renders below the tree, GitHub-style. Best-effort: a
1247 // missing or unreadable file just omits the section.
1248 let readme = entries
1249 .iter()
1250 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
1251 .and_then(|e| {
1252 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1253 Some((
1254 render_markdown(&String::from_utf8_lossy(&bytes)),
1255 e.name.clone(),
1256 ))
1257 });
1258
1259 // A root TODO.md with tasks renders as a kanban board below the README.
1260 let todo_board = entries
1261 .iter()
1262 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
1263 .and_then(|e| {
1264 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1265 let board = todomd::render_board(&String::from_utf8_lossy(&bytes), None)?;
1266 Some((board, e.name.clone()))
1267 });
1268
1269 Ok(layout(
1270 &format!("{owner}/{repo}"),
1271 user.as_ref(),
1272 html! {
1273 (header)
1274 p {
1275 (rev_switcher(&owner, &repo, &rev, &overview))
1276 " · "
1277 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
1278 }
1279 @if let Some((board, name)) = &todo_board {
1280 section.todo-preview {
1281 p.todo-board-head {
1282 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1283 }
1284 input.todo-expand #todo-expand type="checkbox";
1285 div.todo-preview-body { (board) }
1286 label.todo-more for="todo-expand" {}
1287 }
1288 script { (maud::PreEscaped(TODO_PREVIEW_JS)) }
1289 }
1290 @if let Some(c) = &latest {
1291 div.latest-commit {
1292 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1293 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
1294 span.muted style="margin-left:auto" {
1295 (c.author) " · "
1296 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1297 }
1298 }
1299 }
1300 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
1301 @if let Some(lang_bar) = render_languages_bar(&meta.languages_json) {
1302 div.box {
1303 div.readme-head { "Languages" }
1304 div style="padding:8px 16px;" { (lang_bar) }
1305 }
1306 }
1307 @if let Some((rendered, name)) = &readme {
1308 div.box.readme {
1309 div.readme-head {
1310 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1311 }
1312 div.md-body { (rendered) }
1313 }
1314 }
1315 },
1316 ))
1317}
1318
1319/// Drops the clip (and its "show more" toggle) when the preview isn't actually
1320/// taller than the cap, so a short board doesn't get a pointless teaser.
1321const TODO_PREVIEW_JS: &str = r#"
1322(function(){
1323 var d = document.querySelector('.todo-preview');
1324 if (!d) return;
1325 var body = d.querySelector('.todo-preview-body');
1326 if (body.scrollHeight <= body.clientHeight + 8) d.classList.add('fits');
1327})();
1328"#;
1329
1330async fn tree_root(
1331 State(app): State<App>,
1332 user: CurrentUser,
1333 Path((owner, repo, rev)): Path<(String, String, String)>,
1334) -> Result<Markup, Response> {
1335 render_tree(&app, user, &owner, &repo, &rev, "").await
1336}
1337
1338async fn tree_path(
1339 State(app): State<App>,
1340 user: CurrentUser,
1341 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1342) -> Result<Markup, Response> {
1343 render_tree(&app, user, &owner, &repo, &rev, &path).await
1344}
1345
1346async fn render_tree(
1347 app: &App,
1348 CurrentUser(user): CurrentUser,
1349 owner: &str,
1350 repo: &str,
1351 rev: &str,
1352 path: &str,
1353) -> Result<Markup, Response> {
1354 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
1355 let overview = browse::overview(&repo_path).map_err(server_error)?;
1356 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
1357 // Best-effort: a failed walk only costs the per-entry annotations.
1358 let entry_commits =
1359 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
1360 Ok(layout(
1361 &format!("{owner}/{repo}: {path}"),
1362 user.as_ref(),
1363 html! {
1364 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1365 p { (rev_switcher(owner, repo, rev, &overview)) }
1366 (breadcrumbs(owner, repo, rev, path, false))
1367 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
1368 },
1369 ))
1370}
1371
1372/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
1373/// by default; `?plain=1` shows the raw source (toggle links on the page).
1374async fn blob(
1375 State(app): State<App>,
1376 CurrentUser(user): CurrentUser,
1377 csrf: Csrf,
1378 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1379 Query(query): Query<HashMap<String, String>>,
1380) -> Result<Markup, Response> {
1381 let (repo_path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1382 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
1383 .map_err(server_error)?
1384 .ok_or_else(|| not_found("file not found"))?;
1385
1386 // Editing writes a commit onto a branch, so it's offered only to writers
1387 // viewing a text file at a branch tip (not a tag or detached commit). The
1388 // resolved tip is the compare-and-swap guard for board delete actions.
1389 let edit_tip = (!is_binary(&bytes) && access::can_write(&meta, user.as_ref()))
1390 .then(|| browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).ok())
1391 .flatten();
1392 let can_edit = edit_tip.is_some();
1393
1394 let markdown = is_markdown(&path) && !is_binary(&bytes);
1395 // Custom renderers for well-known filenames (the plugin point — add new
1396 // filename → renderer pairs here). TODO.md defaults to a kanban board.
1397 let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes);
1398 let board_actions = edit_tip.as_ref().map(|tip| todomd::BoardActions {
1399 owner: &owner,
1400 repo: &repo,
1401 rev: &rev,
1402 path: &path,
1403 tip,
1404 csrf: &csrf.0,
1405 });
1406 let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md"))
1407 .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes), board_actions.as_ref()))
1408 .flatten();
1409 let rendered = markdown && !query.contains_key("plain") && board.is_none();
1410
1411 let body = if let Some(board) = &board {
1412 board.clone()
1413 } else if is_binary(&bytes) {
1414 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
1415 } else if rendered {
1416 let text = String::from_utf8_lossy(&bytes);
1417 html! { div.md-body { (render_markdown(&text)) } }
1418 } else {
1419 let text = String::from_utf8_lossy(&bytes);
1420 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
1421 let lines = cached_highlight(budget, &oid, &path, &text);
1422 html! {
1423 table.code {
1424 @for (i, line) in lines.iter().enumerate() {
1425 tr {
1426 td.ln { (i + 1) }
1427 td { (PreEscaped(line)) }
1428 }
1429 }
1430 }
1431 }
1432 };
1433
1434 let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
1435 Ok(layout(
1436 &format!("{owner}/{repo}: {path}"),
1437 user.as_ref(),
1438 html! {
1439 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1440 (breadcrumbs(&owner, &repo, &rev, &path, true))
1441 @if can_edit {
1442 p.file-actions {
1443 a.btn.btn-secondary href=(format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(&rev))) {
1444 (icon(Icon::Pencil)) "Edit"
1445 }
1446 @if is_todo {
1447 a.btn.btn-secondary href=(format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(&rev))) {
1448 (icon(Icon::Plus)) "Add task"
1449 }
1450 }
1451 }
1452 }
1453 @if markdown {
1454 p.view-toggle {
1455 span.pill-group {
1456 @if is_todo {
1457 @if board.is_some() { span.pill.active { "Board" } }
1458 @else { a.pill href=(&blob_url) { "Board" } }
1459 @if rendered { span.pill.active { "Rendered" } }
1460 @else { a.pill href=(format!("{blob_url}?md=1")) { "Rendered" } }
1461 } @else if rendered {
1462 span.pill.active { "Rendered" }
1463 } @else {
1464 a.pill href=(&blob_url) { "Rendered" }
1465 }
1466 @if rendered || board.is_some() {
1467 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
1468 } @else {
1469 span.pill.active { "Source" }
1470 }
1471 }
1472 }
1473 }
1474 @if board.is_some() {
1475 // The board supplies its own column structure; an enclosing
1476 // box would just nest frames.
1477 (body)
1478 } @else {
1479 div.box style="overflow-x:auto" { (body) }
1480 }
1481 },
1482 ))
1483}
1484
1485#[derive(serde::Deserialize)]
1486struct EditFileForm {
1487 csrf: String,
1488 /// Expected branch tip the editor saw — the compare-and-swap guard.
1489 expected_tip: String,
1490 message: String,
1491 content: String,
1492}
1493
1494/// Resolve a repo for a web edit, enforcing read+write access and that `rev`
1495/// names a branch (editing advances a branch ref). Returns the repo path and
1496/// the branch tip the editor is working from.
1497async fn resolve_for_edit(
1498 app: &App,
1499 user: Option<&User>,
1500 owner: &str,
1501 repo: &str,
1502 rev: &str,
1503) -> Result<(PathBuf, String), Response> {
1504 let (repo_path, meta) = resolve_repo(app, user, owner, repo).await?;
1505 if user.is_none() {
1506 return Err(Redirect::to("/-/login").into_response());
1507 }
1508 if !access::can_write(&meta, user) {
1509 return Err(forbidden());
1510 }
1511 let tip = browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}"))
1512 .map_err(|_| not_found("not an editable branch"))?;
1513 Ok((repo_path, tip))
1514}
1515
1516/// `GET /{owner}/{repo}/edit/{rev}/{*path}` — textarea editor for an existing
1517/// text file on a branch.
1518async fn edit_form(
1519 State(app): State<App>,
1520 CurrentUser(user): CurrentUser,
1521 csrf: Csrf,
1522 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1523) -> Response {
1524 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1525 Ok(v) => v,
1526 Err(resp) => return resp,
1527 };
1528 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1529 Ok(Some(b)) => b,
1530 Ok(None) => return not_found("file not found"),
1531 Err(e) => return server_error(e),
1532 };
1533 if is_binary(&bytes) {
1534 return bad_request_page(
1535 user.as_ref(),
1536 "Binary files can't be edited in the browser.",
1537 );
1538 }
1539 let content = String::from_utf8_lossy(&bytes).into_owned();
1540 edit_page(
1541 &owner,
1542 &repo,
1543 &rev,
1544 &path,
1545 &content,
1546 &format!("Update {path}"),
1547 &tip,
1548 None,
1549 user.as_ref(),
1550 &csrf.0,
1551 )
1552 .into_response()
1553}
1554
1555/// `POST /{owner}/{repo}/edit/{rev}/{*path}` — commit the edited content.
1556async fn edit_submit(
1557 State(app): State<App>,
1558 CurrentUser(user): CurrentUser,
1559 csrf: Csrf,
1560 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1561 Form(form): Form<EditFileForm>,
1562) -> Response {
1563 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1564 Ok((p, _)) => p,
1565 Err(resp) => return resp,
1566 };
1567 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1568 return resp;
1569 }
1570 let user = user.expect("resolve_for_edit requires a logged-in user");
1571
1572 // Browsers serialize textarea newlines as CRLF; normalize so an edit
1573 // doesn't rewrite every line ending.
1574 let content = form.content.replace("\r\n", "\n");
1575 let message = if form.message.trim().is_empty() {
1576 format!("Update {path}")
1577 } else {
1578 form.message.clone()
1579 };
1580
1581 match anvil_git::edit::commit_file_change(
1582 &repo_path,
1583 &rev,
1584 &form.expected_tip,
1585 &path,
1586 content.as_bytes(),
1587 &user.username,
1588 &user.email,
1589 &message,
1590 ) {
1591 Ok(_) => {
1592 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1593 }
1594 Err(e) => edit_page(
1595 &owner,
1596 &repo,
1597 &rev,
1598 &path,
1599 &content,
1600 &message,
1601 &form.expected_tip,
1602 Some(&e.to_string()),
1603 Some(&user),
1604 &csrf.0,
1605 )
1606 .into_response(),
1607 }
1608}
1609
1610/// The file-editor page: a textarea, a commit-message field, and the
1611/// compare-and-swap tip carried in a hidden field.
1612#[allow(clippy::too_many_arguments)]
1613fn edit_page(
1614 owner: &str,
1615 repo: &str,
1616 rev: &str,
1617 path: &str,
1618 content: &str,
1619 message: &str,
1620 expected_tip: &str,
1621 error: Option<&str>,
1622 user: Option<&User>,
1623 csrf: &str,
1624) -> Markup {
1625 let action = format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(rev));
1626 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1627 let upload_url = format!("/{owner}/{repo}/-/attachments");
1628 layout(
1629 &format!("Edit {path}"),
1630 user,
1631 html! {
1632 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1633 (breadcrumbs(owner, repo, rev, path, true))
1634 p.muted { "Editing on branch " code { (rev) } " — commits as you." }
1635 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1636 form.stack method="post" action=(action) {
1637 (csrf_input(csrf))
1638 input type="hidden" name="expected_tip" value=(expected_tip);
1639 p {
1640 textarea.editor name="content" rows="24" spellcheck="false" autofocus
1641 data-upload-url=(upload_url) data-csrf=(csrf) { (content) }
1642 }
1643 p.upload-hint {
1644 label.btn.btn-secondary.attach-btn {
1645 "Attach image"
1646 input.attach-input type="file" accept="image/*" multiple hidden;
1647 }
1648 " "
1649 span.muted { "or paste/drop one — it's stored outside git and a Markdown link is inserted." }
1650 }
1651 p { label { "Commit message" br; input type="text" name="message" value=(message); } }
1652 p {
1653 button.btn type="submit" { "Commit changes" }
1654 " "
1655 a.btn.btn-secondary href=(cancel) { "Cancel" }
1656 }
1657 }
1658 script { (PreEscaped(EDITOR_JS)) }
1659 },
1660 )
1661}
1662
1663/// Paste/drop-to-upload for the file editor: image clipboard items and dropped
1664/// image files are POSTed to the repo's attachment endpoint as a raw body, and
1665/// the returned Markdown is spliced into the textarea at the cursor. The blob
1666/// is stored outside git; only the URL lands in the file.
1667const EDITOR_JS: &str = r#"
1668(function(){
1669 var ta = document.querySelector('textarea.editor');
1670 if (!ta || !ta.dataset.uploadUrl) return;
1671 var url = ta.dataset.uploadUrl, csrf = ta.dataset.csrf;
1672 function insertAtCursor(text){
1673 var s = ta.selectionStart, e = ta.selectionEnd;
1674 ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
1675 ta.selectionStart = ta.selectionEnd = s + text.length;
1676 ta.focus();
1677 }
1678 function replaceFirst(find, repl){
1679 var i = ta.value.indexOf(find);
1680 if (i >= 0) ta.value = ta.value.slice(0, i) + repl + ta.value.slice(i + find.length);
1681 }
1682 function upload(file){
1683 var token = '![uploading ' + (file.name || 'image') + '…]()';
1684 insertAtCursor(token + '\n');
1685 fetch(url, {
1686 method: 'POST',
1687 headers: {'X-CSRF-Token': csrf, 'Content-Type': file.type || 'application/octet-stream'},
1688 body: file
1689 }).then(function(r){
1690 if (!r.ok) throw new Error('upload failed (' + r.status + ')');
1691 return r.json();
1692 }).then(function(d){
1693 replaceFirst(token, d.markdown);
1694 }).catch(function(err){
1695 replaceFirst(token, '![upload failed]()');
1696 console.error(err);
1697 });
1698 }
1699 ta.addEventListener('paste', function(ev){
1700 var items = (ev.clipboardData || {}).items || [];
1701 for (var i = 0; i < items.length; i++){
1702 if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0){
1703 ev.preventDefault();
1704 upload(items[i].getAsFile());
1705 }
1706 }
1707 });
1708 ta.addEventListener('dragover', function(ev){ ev.preventDefault(); });
1709 ta.addEventListener('drop', function(ev){
1710 var files = (ev.dataTransfer || {}).files || [], imgs = [];
1711 for (var i = 0; i < files.length; i++){
1712 if (files[i].type.indexOf('image/') === 0) imgs.push(files[i]);
1713 }
1714 if (imgs.length){ ev.preventDefault(); imgs.forEach(upload); }
1715 });
1716 // The "Attach image" button (works where paste/drop don't, e.g. mobile):
1717 // a file picker that uploads each chosen image.
1718 var picker = document.querySelector('input.attach-input');
1719 if (picker) picker.addEventListener('change', function(){
1720 var files = picker.files || [];
1721 for (var i = 0; i < files.length; i++){
1722 if (files[i].type.indexOf('image/') === 0) upload(files[i]);
1723 }
1724 picker.value = ''; // let the same file be re-picked
1725 });
1726})();
1727"#;
1728
1729#[derive(serde::Deserialize)]
1730struct AddTaskForm {
1731 csrf: String,
1732 expected_tip: String,
1733 section: String,
1734 title: String,
1735 #[serde(default)]
1736 body: String,
1737}
1738
1739/// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form
1740/// for a `TODO.md`, appending a `- [ ]` item per the todo-md round-trip rules.
1741async fn add_task_form(
1742 State(app): State<App>,
1743 CurrentUser(user): CurrentUser,
1744 csrf: Csrf,
1745 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1746) -> Response {
1747 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1748 Ok(v) => v,
1749 Err(resp) => return resp,
1750 };
1751 if !todomd::is_todo_md(&path) {
1752 return not_found("not a TODO.md");
1753 }
1754 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1755 Ok(Some(b)) => b,
1756 Ok(None) => return not_found("file not found"),
1757 Err(e) => return server_error(e),
1758 };
1759 let sections = todomd::task_sections(&String::from_utf8_lossy(&bytes));
1760 if sections.is_empty() {
1761 return bad_request_page(user.as_ref(), "This TODO.md has no sections to add to.");
1762 }
1763 add_task_page(
1764 &owner,
1765 &repo,
1766 &rev,
1767 &path,
1768 &sections,
1769 "",
1770 "",
1771 &tip,
1772 None,
1773 user.as_ref(),
1774 &csrf.0,
1775 )
1776 .into_response()
1777}
1778
1779/// `POST /{owner}/{repo}/add-task/{rev}/{*path}` — append the task and commit.
1780async fn add_task_submit(
1781 State(app): State<App>,
1782 CurrentUser(user): CurrentUser,
1783 csrf: Csrf,
1784 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1785 Form(form): Form<AddTaskForm>,
1786) -> Response {
1787 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1788 Ok((p, _)) => p,
1789 Err(resp) => return resp,
1790 };
1791 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1792 return resp;
1793 }
1794 let user = user.expect("resolve_for_edit requires a logged-in user");
1795 if !todomd::is_todo_md(&path) {
1796 return not_found("not a TODO.md");
1797 }
1798 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1799 Ok(Some(b)) => b,
1800 Ok(None) => return not_found("file not found"),
1801 Err(e) => return server_error(e),
1802 };
1803 let text = String::from_utf8_lossy(&bytes);
1804 let sections = todomd::task_sections(&text);
1805
1806 // Browsers serialize textarea newlines as CRLF; store LF.
1807 let body = form.body.replace("\r\n", "\n");
1808
1809 let render_err = |msg: &str, csrf: &Csrf| {
1810 add_task_page(
1811 &owner,
1812 &repo,
1813 &rev,
1814 &path,
1815 &sections,
1816 &form.title,
1817 &body,
1818 &form.expected_tip,
1819 Some(msg),
1820 Some(&user),
1821 &csrf.0,
1822 )
1823 .into_response()
1824 };
1825
1826 let Some(updated) = todomd::add_task(&text, &form.section, &form.title, &body) else {
1827 return render_err(
1828 "Couldn't add the task — check the title isn't empty and the section exists.",
1829 &csrf,
1830 );
1831 };
1832
1833 let message = format!("Add task to {}", form.section);
1834 match anvil_git::edit::commit_file_change(
1835 &repo_path,
1836 &rev,
1837 &form.expected_tip,
1838 &path,
1839 updated.as_bytes(),
1840 &user.username,
1841 &user.email,
1842 &message,
1843 ) {
1844 Ok(_) => {
1845 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1846 }
1847 Err(e) => render_err(&e.to_string(), &csrf),
1848 }
1849}
1850
1851#[derive(serde::Deserialize)]
1852struct DeleteTaskForm {
1853 #[serde(default)]
1854 csrf: String,
1855 expected_tip: String,
1856 section: String,
1857 title: String,
1858}
1859
1860/// `POST /{owner}/{repo}/delete-task/{rev}/{*path}` — remove a task/ticket from
1861/// a `TODO.md` (the ✕ on a board card) and commit. Compare-and-swap guarded by
1862/// `expected_tip`, so a concurrent change is rejected rather than clobbered.
1863async fn delete_task(
1864 State(app): State<App>,
1865 CurrentUser(user): CurrentUser,
1866 csrf: Csrf,
1867 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1868 Form(form): Form<DeleteTaskForm>,
1869) -> Response {
1870 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1871 Ok((p, _)) => p,
1872 Err(resp) => return resp,
1873 };
1874 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1875 return resp;
1876 }
1877 let user = user.expect("resolve_for_edit requires a logged-in user");
1878 if !todomd::is_todo_md(&path) {
1879 return not_found("not a TODO.md");
1880 }
1881 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1882 Ok(Some(b)) => b,
1883 Ok(None) => return not_found("file not found"),
1884 Err(e) => return server_error(e),
1885 };
1886 let text = String::from_utf8_lossy(&bytes);
1887
1888 let Some(updated) = todomd::remove_task(&text, &form.section, &form.title) else {
1889 // Already gone (e.g. a double submit) — just show the current board.
1890 return Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev)))
1891 .into_response();
1892 };
1893
1894 let message = format!("Delete task: {}", form.title);
1895 match anvil_git::edit::commit_file_change(
1896 &repo_path,
1897 &rev,
1898 &form.expected_tip,
1899 &path,
1900 updated.as_bytes(),
1901 &user.username,
1902 &user.email,
1903 &message,
1904 ) {
1905 Ok(_) => {
1906 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1907 }
1908 Err(e) => bad_request_page(Some(&user), &format!("Couldn't delete the task: {e}")),
1909 }
1910}
1911
1912#[derive(serde::Deserialize)]
1913struct MoveTaskForm {
1914 #[serde(default)]
1915 csrf: String,
1916 expected_tip: String,
1917 title: String,
1918 from_section: String,
1919 to_section: String,
1920 to_index: usize,
1921}
1922
1923/// `POST /{owner}/{repo}/move-task/{rev}/{*path}` — reorder/move a task on the
1924/// board (drag-and-drop). Write-gated, CSRF-checked, compare-and-swap on the
1925/// branch tip. Driven by `fetch`, so it returns bare status codes.
1926async fn move_task(
1927 State(app): State<App>,
1928 CurrentUser(user): CurrentUser,
1929 csrf: Csrf,
1930 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1931 Form(form): Form<MoveTaskForm>,
1932) -> Response {
1933 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1934 Ok((p, _)) => p,
1935 Err(resp) => return resp,
1936 };
1937 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1938 return resp;
1939 }
1940 let user = user.expect("resolve_for_edit requires a logged-in user");
1941 if !todomd::is_todo_md(&path) {
1942 return not_found("not a TODO.md");
1943 }
1944 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1945 Ok(Some(b)) => b,
1946 Ok(None) => return not_found("file not found"),
1947 Err(e) => return server_error(e),
1948 };
1949 let text = String::from_utf8_lossy(&bytes);
1950
1951 let Some(updated) = todomd::move_task(
1952 &text,
1953 &form.title,
1954 &form.from_section,
1955 &form.to_section,
1956 form.to_index,
1957 ) else {
1958 return (StatusCode::BAD_REQUEST, "could not move task").into_response();
1959 };
1960
1961 let message = if form.from_section == form.to_section {
1962 format!("Reorder {} in {}", form.title, form.to_section)
1963 } else {
1964 format!("Move {} to {}", form.title, form.to_section)
1965 };
1966 match anvil_git::edit::commit_file_change(
1967 &repo_path,
1968 &rev,
1969 &form.expected_tip,
1970 &path,
1971 updated.as_bytes(),
1972 &user.username,
1973 &user.email,
1974 &message,
1975 ) {
1976 // A no-op drop (dropped back in place) is success, not an error.
1977 Ok(_) | Err(anvil_git::edit::EditError::NoChanges) => StatusCode::OK.into_response(),
1978 Err(anvil_git::edit::EditError::BranchMoved { .. }) => {
1979 (StatusCode::CONFLICT, "branch moved — reload").into_response()
1980 }
1981 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
1982 }
1983}
1984
1985/// The add-task form: a section dropdown, a title field, and a Markdown
1986/// description (which supports paste/drop image upload, like the file editor).
1987#[allow(clippy::too_many_arguments)]
1988fn add_task_page(
1989 owner: &str,
1990 repo: &str,
1991 rev: &str,
1992 path: &str,
1993 sections: &[String],
1994 title: &str,
1995 body: &str,
1996 expected_tip: &str,
1997 error: Option<&str>,
1998 user: Option<&User>,
1999 csrf: &str,
2000) -> Markup {
2001 let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev));
2002 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
2003 let upload_url = format!("/{owner}/{repo}/-/attachments");
2004 layout(
2005 &format!("Add task · {path}"),
2006 user,
2007 html! {
2008 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
2009 (breadcrumbs(owner, repo, rev, path, true))
2010 h2 { "Add a task" }
2011 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
2012 form.stack method="post" action=(action) {
2013 (csrf_input(csrf))
2014 input type="hidden" name="expected_tip" value=(expected_tip);
2015 p { label { "Section" br;
2016 select name="section" {
2017 @for s in sections { option value=(s) { (s) } }
2018 }
2019 } }
2020 p { label { "Title" br;
2021 input type="text" name="title" value=(title) placeholder="Short ticket title" autofocus;
2022 } }
2023 p { label { "Description" br;
2024 textarea.editor name="body" rows="10" spellcheck="false"
2025 placeholder="Markdown — attach an image with the button below, or paste/drop one"
2026 data-upload-url=(upload_url) data-csrf=(csrf) { (body) }
2027 } }
2028 p.upload-hint {
2029 label.btn.btn-secondary.attach-btn {
2030 "Attach image"
2031 input.attach-input type="file" accept="image/*" multiple hidden;
2032 }
2033 " "
2034 span.muted { "stored outside git; a Markdown link is inserted into the description." }
2035 }
2036 p {
2037 button.btn type="submit" { "Add task" }
2038 " "
2039 a.btn.btn-secondary href=(cancel) { "Cancel" }
2040 }
2041 }
2042 script { (PreEscaped(EDITOR_JS)) }
2043 },
2044 )
2045}
2046
2047/// A 400 page for malformed edit requests (binary file, no sections, …).
2048fn bad_request_page(user: Option<&User>, message: &str) -> Response {
2049 (
2050 StatusCode::BAD_REQUEST,
2051 layout(
2052 "Can't edit",
2053 user,
2054 html! { h1 { "Can't edit" } p.muted { (message) } },
2055 ),
2056 )
2057 .into_response()
2058}
2059
2060/// Pick the singular or plural noun for a count (`1 branch` / `2 branches`).
2061fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
2062 if n == 1 { one } else { many }
2063}
2064
2065/// Whether a path should be treated as markdown (by extension).
2066fn is_markdown(path: &str) -> bool {
2067 std::path::Path::new(path)
2068 .extension()
2069 .and_then(|e| e.to_str())
2070 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
2071}
2072
2073/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
2074///
2075/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
2076/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
2077/// link and image destinations are dropped.
2078pub(crate) fn render_markdown(text: &str) -> Markup {
2079 use pulldown_cmark::{
2080 Event,
2081 Options,
2082 Parser,
2083 Tag,
2084 html,
2085 };
2086
2087 fn safe_url(dest: &str) -> bool {
2088 let d = dest.trim().to_ascii_lowercase();
2089 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
2090 }
2091
2092 let opts = Options::ENABLE_TABLES
2093 | Options::ENABLE_STRIKETHROUGH
2094 | Options::ENABLE_TASKLISTS
2095 | Options::ENABLE_FOOTNOTES;
2096 let events = Parser::new_ext(text, opts).map(|ev| match ev {
2097 Event::Html(h) => Event::Text(h),
2098 Event::InlineHtml(h) => Event::Text(h),
2099 Event::Start(Tag::Link {
2100 link_type,
2101 dest_url,
2102 title,
2103 id,
2104 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
2105 link_type,
2106 dest_url: "".into(),
2107 title,
2108 id,
2109 }),
2110 Event::Start(Tag::Image {
2111 link_type,
2112 dest_url,
2113 title,
2114 id,
2115 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
2116 link_type,
2117 dest_url: "".into(),
2118 title,
2119 id,
2120 }),
2121 e => e,
2122 });
2123 let mut out = String::new();
2124 html::push_html(&mut out, events);
2125 PreEscaped(out)
2126}
2127
2128/// Render a language breakdown bar showing percentages of each detected language.
2129/// Displays as a horizontal bar with each language's proportion.
2130pub(crate) fn render_languages_bar(languages_json: &str) -> Option<Markup> {
2131 if languages_json.is_empty() || languages_json == "[]" {
2132 return None;
2133 }
2134
2135 // Parse the JSON array
2136 let langs: Vec<serde_json::Value> = serde_json::from_str(languages_json).ok()?;
2137 if langs.is_empty() {
2138 return None;
2139 }
2140
2141 // Color palette for languages (simple heuristic)
2142 let color_for_lang = |lang: &str| -> &'static str {
2143 match lang {
2144 "Rust" => "#CE422B",
2145 "Python" => "#3776AB",
2146 "JavaScript" => "#F7DF1E",
2147 "TypeScript" => "#3178C6",
2148 "Go" => "#00ADD8",
2149 "Java" => "#007396",
2150 "C++" => "#00599C",
2151 "C#" => "#239120",
2152 "Ruby" => "#CC342D",
2153 "PHP" => "#777BB4",
2154 "Markdown" => "#083FA1",
2155 "HTML" => "#E34C26",
2156 "CSS" => "#563D7C",
2157 "SQL" => "#336791",
2158 _ => "#999999",
2159 }
2160 };
2161
2162 let mut html = String::from(
2163 r#"<div class="language-bar" style="display:flex;border-radius:4px;overflow:hidden;height:20px;background:#f0f0f0;">"#,
2164 );
2165 for lang_obj in langs {
2166 if let (Some(lang), Some(percent)) = (
2167 lang_obj.get("lang").and_then(|v| v.as_str()),
2168 lang_obj.get("percent").and_then(|v| v.as_f64()),
2169 ) {
2170 let color = color_for_lang(lang);
2171 html.push_str(&format!(
2172 r#"<div style="width:{:.1}%;background-color:{};tooltip:'{}';height:100%" title="{}"></div>"#,
2173 percent, color, lang, lang
2174 ));
2175 }
2176 }
2177 html.push_str("</div>");
2178
2179 Some(PreEscaped(html))
2180}
2181
2182/// How far back the per-entry "latest commit" walk looks. Entries last touched
2183/// beyond this many commits just lose the annotation.
2184const ENTRY_LOG_WALK: usize = 400;
2185
2186/// Folder or file icon for an entry row (tree listings, pages, artifacts).
2187pub(crate) fn entry_icon(is_dir: bool) -> Markup {
2188 if is_dir {
2189 icon_with(Icon::Folder, "icon dir")
2190 } else {
2191 icon(Icon::File)
2192 }
2193}
2194
2195/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
2196pub(crate) fn fmt_size(bytes: i64) -> String {
2197 let b = bytes.max(0) as f64;
2198 match b {
2199 b if b < 1024.0 => format!("{bytes} B"),
2200 b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
2201 b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
2202 b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
2203 }
2204}
2205
2206/// Percent-encode a ref name for use as one path segment in a URL. Axum
2207/// matches routes before decoding, so an encoded `/` keeps a branch like
2208/// `feat/x` inside the single `{rev}` segment.
2209pub(crate) fn enc_ref(name: &str) -> String {
2210 name.replace('%', "%25")
2211 .replace('/', "%2F")
2212 .replace('?', "%3F")
2213 .replace('#', "%23")
2214}
2215
2216/// Branch/tag switcher: a dropdown over the current rev linking each ref to
2217/// its tree view. Branch names, tag names, and commit ids all work as `rev`.
2218fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup {
2219 html! {
2220 details.nav-menu.rev-menu {
2221 summary { span.pill { (rev) } }
2222 div.nav-dropdown.left {
2223 @if !overview.branches.is_empty() {
2224 div.dd-head { "Branches" }
2225 @for b in &overview.branches {
2226 a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) }
2227 }
2228 }
2229 @if !overview.tags.is_empty() {
2230 div.dd-head { "Tags" }
2231 @for t in &overview.tags {
2232 a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) }
2233 }
2234 }
2235 }
2236 }
2237 }
2238}
2239
2240/// Render a tree listing as a box of rows; directories link to `tree`, files to
2241/// `blob`. Each entry also shows the subject of (and links to) the latest
2242/// commit that touched it, when `latest` has one for it.
2243fn tree_table(
2244 owner: &str,
2245 repo: &str,
2246 rev: &str,
2247 path: &str,
2248 entries: &[browse::TreeEntry],
2249 latest: &BTreeMap<String, browse::CommitInfo>,
2250) -> Markup {
2251 let join = |name: &str| {
2252 if path.is_empty() {
2253 name.to_string()
2254 } else {
2255 format!("{path}/{name}")
2256 }
2257 };
2258 html! {
2259 div.box {
2260 @if !path.is_empty() {
2261 div.row {
2262 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
2263 }
2264 }
2265 @for e in entries {
2266 @let child = join(&e.name);
2267 @let kind = if e.is_dir { "tree" } else { "blob" };
2268 div.row {
2269 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
2270 (entry_icon(e.is_dir))
2271 (e.name) @if e.is_dir { "/" }
2272 }
2273 @if let Some(c) = latest.get(&e.name) {
2274 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
2275 span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
2276 }
2277 }
2278 }
2279 }
2280 }
2281}
2282
2283fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
2284 match path.rsplit_once('/') {
2285 Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)),
2286 None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)),
2287 }
2288}
2289
2290/// Path breadcrumbs. `is_blob` marks the final component as a file.
2291fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
2292 // Precompute (label, cumulative_path) for each path component.
2293 let mut crumbs: Vec<(String, String)> = Vec::new();
2294 let mut acc = String::new();
2295 for part in path.split('/').filter(|p| !p.is_empty()) {
2296 if !acc.is_empty() {
2297 acc.push('/');
2298 }
2299 acc.push_str(part);
2300 crumbs.push((part.to_string(), acc.clone()));
2301 }
2302 let last = crumbs.len();
2303 html! {
2304 div.crumbs {
2305 a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
2306 @for (i, (label, cum)) in crumbs.iter().enumerate() {
2307 " / "
2308 @if i + 1 == last && is_blob {
2309 span { (label) }
2310 } @else {
2311 a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
2312 }
2313 }
2314 }
2315 }
2316}
2317
2318/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
2319async fn commits(
2320 State(app): State<App>,
2321 CurrentUser(user): CurrentUser,
2322 Path((owner, repo, rev)): Path<(String, String, String)>,
2323) -> Result<Markup, Response> {
2324 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2325 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
2326
2327 // Map each commit oid to its latest run status, for inline badges. One query
2328 // for the repo's recent runs; first match wins (list is newest-first).
2329 let runs = ci::list_by_repo(&app.db, meta.id, 200)
2330 .await
2331 .unwrap_or_default();
2332 let mut status_of: HashMap<&str, &str> = HashMap::new();
2333 for r in &runs {
2334 status_of
2335 .entry(r.commit.as_str())
2336 .or_insert(r.status.as_str());
2337 }
2338
2339 Ok(layout(
2340 &format!("{owner}/{repo}: commits"),
2341 user.as_ref(),
2342 html! {
2343 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
2344 ul.commit-list {
2345 @for c in &log {
2346 li {
2347 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
2348 @if let Some(st) = status_of.get(c.id.as_str()) {
2349 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
2350 }
2351 span { (c.summary) }
2352 span.muted style="margin-left:auto" {
2353 (c.author) " · "
2354 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
2355 }
2356 }
2357 }
2358 }
2359 },
2360 ))
2361}
2362
2363/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
2364async fn commit(
2365 State(app): State<App>,
2366 CurrentUser(user): CurrentUser,
2367 Path((owner, repo, id)): Path<(String, String, String)>,
2368) -> Result<Markup, Response> {
2369 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2370 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
2371 Ok(layout(
2372 &format!("{owner}/{repo}: {}", detail.info.short),
2373 user.as_ref(),
2374 html! {
2375 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
2376 p { (detail.info.summary) }
2377 p.muted {
2378 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
2379 span.sha { (detail.info.id) }
2380 @if let Some(parent) = &detail.parent {
2381 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
2382 }
2383 " · "
2384 a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
2385 }
2386 @if detail.changes.is_empty() {
2387 p.muted { "No file changes." }
2388 }
2389 @for change in &detail.changes {
2390 (render_file_diff(change))
2391 }
2392 },
2393 ))
2394}
2395
2396/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
2397async fn ci_runs(
2398 State(app): State<App>,
2399 CurrentUser(user): CurrentUser,
2400 Path((owner, repo)): Path<(String, String)>,
2401) -> Result<Markup, Response> {
2402 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2403 let runs = ci::list_by_repo(&app.db, meta.id, 100)
2404 .await
2405 .map_err(server_error)?;
2406 Ok(layout(
2407 &format!("{owner}/{repo}: CI"),
2408 user.as_ref(),
2409 html! {
2410 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
2411 @if runs.is_empty() {
2412 p.muted {
2413 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
2414 " pipeline and push to trigger one."
2415 }
2416 } @else {
2417 div.box {
2418 @for r in &runs {
2419 div.row {
2420 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
2421 (status_badge(&r.status))
2422 span.sha { (short_commit(&r.commit)) }
2423 span { (r.ref_name) }
2424 }
2425 span.muted { (fmt_time(r.created_at)) }
2426 }
2427 }
2428 }
2429 }
2430 },
2431 ))
2432}
2433
2434/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
2435async fn ci_run(
2436 State(app): State<App>,
2437 CurrentUser(user): CurrentUser,
2438 Path((owner, repo, id)): Path<(String, String, i64)>,
2439) -> Result<Markup, Response> {
2440 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2441 let run = ci::get(&app.db, id)
2442 .await
2443 .map_err(server_error)?
2444 .filter(|r| r.repo_id == meta.id)
2445 .ok_or_else(|| not_found("no such CI run"))?;
2446 let artifacts = ci::artifacts_for_run(&app.db, run.id)
2447 .await
2448 .map_err(server_error)?;
2449 Ok(layout(
2450 &format!("{owner}/{repo}: CI #{}", run.id),
2451 user.as_ref(),
2452 html! {
2453 h1 {
2454 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
2455 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
2456 " · #" (run.id)
2457 }
2458 p {
2459 (status_badge(&run.status))
2460 " "
2461 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
2462 " " span.muted { (run.ref_name) }
2463 }
2464 p.muted {
2465 "queued " (fmt_time(run.created_at))
2466 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
2467 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
2468 @if let Some(d) = run_duration(&run) { " · took " (d) }
2469 }
2470 @if !artifacts.is_empty() {
2471 h2 { "Artifacts" }
2472 div.box {
2473 @for a in &artifacts {
2474 div.row {
2475 a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
2476 (entry_icon(a.is_dir))
2477 (a.name)
2478 @if a.browse { " " span.pill { "site" } }
2479 @else if a.is_dir { ".tar.gz" }
2480 }
2481 span.muted {
2482 (artifact_meta_chips(&a.meta))
2483 (fmt_size(a.size))
2484 }
2485 }
2486 }
2487 }
2488 }
2489 @if run.log.is_empty() {
2490 p.muted { "No output yet." }
2491 } @else {
2492 pre.log { (run.log) }
2493 }
2494 },
2495 ))
2496}
2497
2498/// Render an artifact's extractor metadata (a JSON object of key → value) as
2499/// inline `key: value` chips before the size.
2500fn artifact_meta_chips(meta: &str) -> Markup {
2501 let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
2502 html! {
2503 @for (k, v) in &map {
2504 span.pill title=(k) { (k) ": " (v) }
2505 " "
2506 }
2507 }
2508}
2509
2510/// A coloured status pill for a CI run status string.
2511fn status_badge(status: &str) -> Markup {
2512 html! { span class=(format!("st {status}")) { (status) } }
2513}
2514
2515/// First 8 hex chars of a commit oid (for compact display).
2516fn short_commit(commit: &str) -> &str {
2517 &commit[..commit.len().min(8)]
2518}
2519
2520/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
2521fn run_duration(run: &CiRun) -> Option<String> {
2522 if run.started_at > 0 && run.finished_at >= run.started_at {
2523 Some(format!("{}s", run.finished_at - run.started_at))
2524 } else {
2525 None
2526 }
2527}
2528
2529/// Render one file's diff (added/deleted/modified) as a unified line diff.
2530/// A file diff bigger than this many rows starts collapsed (its header still
2531/// shows the +/− counts; clicking expands it — native `details`, no JS).
2532const DIFF_COLLAPSE_ROWS: usize = 400;
2533
2534fn render_file_diff(change: &FileChange) -> Markup {
2535 let (badge_cls, badge) = match change.kind {
2536 ChangeKind::Added => ("add", "added"),
2537 ChangeKind::Deleted => ("del", "deleted"),
2538 ChangeKind::Modified => ("mod", "modified"),
2539 };
2540 let head = |stat: Markup| {
2541 html! {
2542 summary.head {
2543 span class=(format!("badge {badge_cls}")) { (badge) }
2544 span { (change.path) }
2545 span.stat { (stat) }
2546 }
2547 }
2548 };
2549
2550 let binary = change.old.as_deref().is_some_and(is_binary)
2551 || change.new.as_deref().is_some_and(is_binary);
2552 if binary {
2553 return html! {
2554 details.file-diff open {
2555 (head(html! { span.muted { "binary" } }))
2556 div.box { div.row { span.muted { "Binary file" } } }
2557 }
2558 };
2559 }
2560
2561 let old = change
2562 .old
2563 .as_deref()
2564 .map(|b| String::from_utf8_lossy(b).into_owned())
2565 .unwrap_or_default();
2566 let new = change
2567 .new
2568 .as_deref()
2569 .map(|b| String::from_utf8_lossy(b).into_owned())
2570 .unwrap_or_default();
2571 let diff = TextDiff::from_lines(&old, &new);
2572 let (mut adds, mut dels) = (0usize, 0usize);
2573 for c in diff.iter_all_changes() {
2574 match c.tag() {
2575 ChangeTag::Insert => adds += 1,
2576 ChangeTag::Delete => dels += 1,
2577 ChangeTag::Equal => {}
2578 }
2579 }
2580 // Hunks: changed lines plus 3 lines of context, not the whole file.
2581 let groups = diff.grouped_ops(3);
2582 let rendered_rows: usize = groups
2583 .iter()
2584 .flatten()
2585 .map(|op| diff.iter_changes(op).count())
2586 .sum();
2587
2588 html! {
2589 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
2590 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
2591 (diff_table(&diff, &groups, old.lines().count()))
2592 }
2593 }
2594}
2595
2596/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
2597/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
2598/// (including before the first hunk and after the last).
2599fn diff_table<'a>(
2600 diff: &TextDiff<'a, 'a, '_, str>,
2601 groups: &[Vec<similar::DiffOp>],
2602 old_total: usize,
2603) -> Markup {
2604 let gap_row = |n: usize| {
2605 html! {
2606 @if n > 0 {
2607 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
2608 }
2609 }
2610 };
2611 // Unchanged-line gap before each group, and after the last one.
2612 let mut prev_end = 0usize; // end of the previous group, in old-file lines
2613 let mut with_gaps = Vec::with_capacity(groups.len());
2614 for group in groups {
2615 let start = group.first().map_or(prev_end, |op| op.old_range().start);
2616 with_gaps.push((start.saturating_sub(prev_end), group));
2617 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
2618 }
2619 let trailing = old_total.saturating_sub(prev_end);
2620
2621 html! {
2622 table.code.diff {
2623 @for (gap, group) in &with_gaps {
2624 (gap_row(*gap))
2625 @for op in group.iter() {
2626 @for change in diff.iter_changes(op) {
2627 @let (sign, cls) = match change.tag() {
2628 ChangeTag::Delete => ("-", "del"),
2629 ChangeTag::Insert => ("+", "ins"),
2630 ChangeTag::Equal => (" ", ""),
2631 };
2632 tr class=(cls) {
2633 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
2634 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
2635 td.sign { (sign) }
2636 td { (change.value().trim_end_matches('\n')) }
2637 }
2638 }
2639 }
2640 }
2641 (gap_row(trailing))
2642 }
2643 }
2644}
2645
2646/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
2647fn highlighter() -> &'static (SyntaxSet, Theme) {
2648 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
2649 HL.get_or_init(|| {
2650 let syntaxes = SyntaxSet::load_defaults_newlines();
2651 let themes = ThemeSet::load_defaults();
2652 let theme = themes
2653 .themes
2654 .get("Monokai Extended")
2655 .or_else(|| themes.themes.get("Solarized (dark)"))
2656 .or_else(|| themes.themes.values().next())
2657 .cloned()
2658 .expect("at least one default theme");
2659 (syntaxes, theme)
2660 })
2661}
2662
2663/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
2664/// blob's rendered HTML is immutable for its object id (the extension is part
2665/// of the key because it picks the syntax), so each file is highlighted once
2666/// rather than once per request — highlighting large files is by far the most
2667/// expensive thing a page view can do. The budget is
2668/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
2669/// RAM-constrained hosts). Concurrent misses may both compute and the last
2670/// insert wins; that's benign.
2671fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
2672 if budget_bytes == 0 {
2673 return Arc::new(highlight(path, text));
2674 }
2675 struct Cache {
2676 lru: lru::LruCache<String, Arc<Vec<String>>>,
2677 bytes: usize,
2678 }
2679 fn cost(key: &str, lines: &[String]) -> usize {
2680 key.len() + lines.iter().map(String::len).sum::<usize>()
2681 }
2682 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
2683 let cache = CACHE.get_or_init(|| {
2684 Mutex::new(Cache {
2685 lru: lru::LruCache::unbounded(),
2686 bytes: 0,
2687 })
2688 });
2689
2690 let ext = std::path::Path::new(path)
2691 .extension()
2692 .and_then(|e| e.to_str())
2693 .unwrap_or("");
2694 let key = format!("{oid}\x00{ext}");
2695 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
2696 return hit.clone();
2697 }
2698
2699 let lines = Arc::new(highlight(path, text));
2700 let mut c = cache.lock().expect("cache lock");
2701 c.bytes += cost(&key, &lines);
2702 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
2703 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
2704 }
2705 // Evict oldest entries until we're back under budget. An entry larger than
2706 // the whole budget evicts itself — memory stays bounded, it just never caches.
2707 while c.bytes > budget_bytes {
2708 let Some((k, v)) = c.lru.pop_lru() else { break };
2709 c.bytes -= cost(&k, &v);
2710 }
2711 lines
2712}
2713
2714/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
2715/// Falls back to escaped plain text for large files or on any failure.
2716fn highlight(path: &str, text: &str) -> Vec<String> {
2717 if text.len() > 512 * 1024 {
2718 return text.lines().map(escape).collect();
2719 }
2720 let (syntaxes, theme) = highlighter();
2721 let syntax = std::path::Path::new(path)
2722 .extension()
2723 .and_then(|e| e.to_str())
2724 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
2725 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
2726 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
2727
2728 let mut h = HighlightLines::new(syntax, theme);
2729 text.lines()
2730 .map(|line| match h.highlight_line(line, syntaxes) {
2731 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
2732 .unwrap_or_else(|_| escape(line)),
2733 Err(_) => escape(line),
2734 })
2735 .collect()
2736}
2737
2738fn escape(s: &str) -> String {
2739 s.replace('&', "&amp;")
2740 .replace('<', "&lt;")
2741 .replace('>', "&gt;")
2742}
2743
2744/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
2745pub(crate) fn fmt_time(secs: i64) -> String {
2746 match OffsetDateTime::from_unix_timestamp(secs) {
2747 Ok(t) => format!(
2748 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
2749 t.year(),
2750 u8::from(t.month()),
2751 t.day(),
2752 t.hour(),
2753 t.minute()
2754 ),
2755 Err(_) => secs.to_string(),
2756 }
2757}
2758
2759/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
2760pub(crate) fn fmt_relative(secs: i64) -> String {
2761 relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
2762}
2763
2764fn relative_to(secs: i64, now: i64) -> String {
2765 fn ago(n: i64, one: &str, unit: &str) -> String {
2766 if n == 1 {
2767 one.to_string()
2768 } else {
2769 format!("{n} {unit}s ago")
2770 }
2771 }
2772 let delta = now - secs;
2773 if delta < 60 {
2774 return "just now".to_string();
2775 }
2776 let minutes = delta / 60;
2777 if minutes < 60 {
2778 return ago(minutes, "1 minute ago", "minute");
2779 }
2780 let hours = delta / 3600;
2781 if hours < 24 {
2782 return ago(hours, "1 hour ago", "hour");
2783 }
2784 let days = delta / 86_400;
2785 if days < 7 {
2786 return ago(days, "yesterday", "day");
2787 }
2788 let weeks = days / 7;
2789 if weeks < 5 {
2790 return ago(weeks, "last week", "week");
2791 }
2792 let months = days / 30;
2793 if months < 12 {
2794 return ago(months, "last month", "month");
2795 }
2796 ago(days / 365, "last year", "year")
2797}
2798
2799/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
2800fn is_binary(bytes: &[u8]) -> bool {
2801 bytes.iter().take(8192).any(|&b| b == 0)
2802}
2803
2804#[cfg(test)]
2805mod tests {
2806 use super::*;
2807
2808 #[test]
2809 fn markdown_by_extension_only() {
2810 assert!(is_markdown("README.md"));
2811 assert!(is_markdown("docs/guide.MarkDown"));
2812 assert!(!is_markdown("main.rs"));
2813 assert!(!is_markdown("md")); // no extension
2814 }
2815
2816 // Repo content is untrusted; rendered markdown must not become stored XSS.
2817 #[test]
2818 fn rendered_markdown_neutralizes_html_and_script_urls() {
2819 let out = render_markdown(
2820 "# title\n\n<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n![y](data:text/html,evil)\n\n[ok](https://example.com)\n",
2821 )
2822 .into_string();
2823 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
2824 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
2825 assert!(
2826 out.contains("&lt;script&gt;"),
2827 "raw HTML kept as text: {out}"
2828 );
2829 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
2830 assert!(!out.contains("data:"), "data URL dropped: {out}");
2831 assert!(
2832 out.contains(r#"href="https://example.com""#),
2833 "normal links survive: {out}"
2834 );
2835 }
2836
2837 #[test]
2838 fn relative_time_buckets() {
2839 const NOW: i64 = 1_000_000_000;
2840 let at = |delta: i64| relative_to(NOW - delta, NOW);
2841 assert_eq!(at(0), "just now");
2842 assert_eq!(at(59), "just now");
2843 assert_eq!(at(60), "1 minute ago");
2844 assert_eq!(at(45 * 60), "45 minutes ago");
2845 assert_eq!(at(3600), "1 hour ago");
2846 assert_eq!(at(23 * 3600), "23 hours ago");
2847 assert_eq!(at(86_400), "yesterday");
2848 assert_eq!(at(3 * 86_400), "3 days ago");
2849 assert_eq!(at(8 * 86_400), "last week");
2850 assert_eq!(at(20 * 86_400), "2 weeks ago");
2851 assert_eq!(at(40 * 86_400), "last month");
2852 assert_eq!(at(200 * 86_400), "6 months ago");
2853 assert_eq!(at(400 * 86_400), "last year");
2854 assert_eq!(at(900 * 86_400), "2 years ago");
2855 }
2856}