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