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