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