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