vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add browser auth, contributor access, and gated CI
6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073
Add browser auth, contributor access, and gated CI Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn. Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page. Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working. Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.
server/web/templates.go +174−2
| @@ -6,6 +6,7 @@ const templates = ` | |||
| 6 | 6 | <head> | |
| 7 | 7 | <meta charset="utf-8"> | |
| 8 | 8 | <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 9 | <meta name="csrf" content="{{.CSRF}}"> | ||
| 9 | 10 | <title>{{.Title}} · Vole</title> | |
| 10 | 11 | <style> | |
| 11 | 12 | :root{ | |
| @@ -29,7 +30,18 @@ th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase; | |||
| 29 | 30 | code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} | |
| 30 | 31 | code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em} | |
| 31 | 32 | pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow:auto;font-size:.86rem;line-height:1.5} | |
| 32 | select{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.35rem .55rem;font:inherit;font-size:.9rem} | ||
| 33 | select,input[type=text],input[type=password],textarea{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.45rem .55rem;font:inherit;font-size:.9rem} | ||
| 34 | input[type=text],input[type=password],textarea{width:100%;max-width:32rem} | ||
| 35 | textarea{min-height:8rem;resize:vertical} | ||
| 36 | label{display:block;margin:.6rem 0;color:var(--muted);font-size:.9rem} | ||
| 37 | button{background:var(--accent);color:#08101d;border:0;border-radius:6px;padding:.45rem .8rem;font:inherit;font-weight:600;cursor:pointer} | ||
| 38 | button:disabled{opacity:.5;cursor:default} | ||
| 39 | button.link{border:0;background:none;color:var(--accent);padding:0;font:inherit;font-weight:400;cursor:pointer} | ||
| 40 | button.link:hover{text-decoration:underline} | ||
| 41 | form.inline{display:inline} | ||
| 42 | .auth{max-width:36rem} | ||
| 43 | .error{color:var(--bad)} | ||
| 44 | .bad{color:var(--bad)} | ||
| 33 | 45 | .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0} | |
| 34 | 46 | .row{display:flex;gap:1.5rem;flex-wrap:wrap} | |
| 35 | 47 | .row>div{flex:1 1 18rem} | |
| @@ -66,10 +78,16 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 66 | 78 | <body> | |
| 67 | 79 | <header> | |
| 68 | 80 | <a class="brand" href="/">vole</a> | |
| 69 | <nav><a href="/runners">runners</a> · read-only · push over ssh</nav> | ||
| 81 | <nav> | ||
| 82 | {{if .LoggedIn}}<span class="muted">{{.User}}</span> · <a href="/account">account</a> · | ||
| 83 | <form class="inline" method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link" type="submit">log out</button></form> | ||
| 84 | {{else}}<a href="/login">log in</a>{{end}} | ||
| 85 | · <a href="/runners">runners</a> · push over ssh | ||
| 86 | </nav> | ||
| 70 | 87 | </header> | |
| 71 | 88 | <main> | |
| 72 | 89 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} | |
| 90 | <script src="/auth.js" defer></script> | ||
| 73 | 91 | {{end}} | |
| 74 | 92 | ||
| 75 | 93 | {{define "footer"}} | |
| @@ -168,6 +186,28 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 168 | 186 | <h2>Recent commits</h2> | |
| 169 | 187 | {{template "commitlist" .}} | |
| 170 | 188 | ||
| 189 | {{if .ContributorBranches}} | ||
| 190 | <h2>Contributor branches</h2> | ||
| 191 | <p class="muted">CI does not run for these branches until a maintainer allows the current commit.</p> | ||
| 192 | <table> | ||
| 193 | <tr><th>Branch</th><th>CI</th></tr> | ||
| 194 | {{range .ContributorBranches}} | ||
| 195 | <tr> | ||
| 196 | <td><code>{{.}}</code></td> | ||
| 197 | <td> | ||
| 198 | {{if $.CanAllowCI}} | ||
| 199 | <form method="post" action="/r/{{$.Name}}/ci/allow"> | ||
| 200 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> | ||
| 201 | <input type="hidden" name="ref" value="refs/heads/{{.}}"> | ||
| 202 | <button type="submit">Allow CI for this tip</button> | ||
| 203 | </form> | ||
| 204 | {{else}}<span class="muted">waiting for a maintainer to allow it</span>{{end}} | ||
| 205 | </td> | ||
| 206 | </tr> | ||
| 207 | {{end}} | ||
| 208 | </table> | ||
| 209 | {{end}} | ||
| 210 | |||
| 171 | 211 | {{template "repo-ci" .}} | |
| 172 | 212 | {{template "footer" .}} | |
| 173 | 213 | {{end}} | |
| @@ -281,6 +321,14 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 281 | 321 | {{if .Filtered}}<a href="/r/{{.Name}}/issues">clear</a>{{end}} | |
| 282 | 322 | </form> | |
| 283 | 323 | <p class="muted">{{.Open}} open · {{.Total}} total</p> | |
| 324 | {{if .CanDiscuss}} | ||
| 325 | <form method="post" action="/r/{{.Name}}/issues" class="card"> | ||
| 326 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 327 | <label>Title <input type="text" name="title" required maxlength="200"></label> | ||
| 328 | <label>Description <textarea name="body" placeholder="Markdown is welcome."></textarea></label> | ||
| 329 | <button type="submit">Open issue</button> | ||
| 330 | </form> | ||
| 331 | {{end}} | ||
| 284 | 332 | {{if .Labels}} | |
| 285 | 333 | <p class="muted">Labels: | |
| 286 | 334 | {{range .Labels}}<a class="label" href="/r/{{$.Name}}/issues?label={{.}}">{{.}}</a>{{end}} | |
| @@ -339,6 +387,13 @@ labels, then the body as markdown, which you can write with any editor.</p> | |||
| 339 | 387 | <p class="muted">No comments yet. From a clone of this repository: | |
| 340 | 388 | <code>vole issue comment {{.Issue.ID}} -m "..."</code></p> | |
| 341 | 389 | {{end}} | |
| 390 | {{if .CanDiscuss}} | ||
| 391 | <form method="post" action="/r/{{.Name}}/issues/{{.Issue.ID}}/comments" class="card"> | ||
| 392 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 393 | <label>Comment <textarea name="body" required></textarea></label> | ||
| 394 | <button type="submit">Comment</button> | ||
| 395 | </form> | ||
| 396 | {{end}} | ||
| 342 | 397 | {{if .Backlinks}} | |
| 343 | 398 | <h2>Referenced in commits</h2> | |
| 344 | 399 | <table> | |
| @@ -368,6 +423,19 @@ labels, then the body as markdown, which you can write with any editor.</p> | |||
| 368 | 423 | {{template "header" .}} | |
| 369 | 424 | <h1><a href="/r/{{.Name}}">{{.Name}}</a> · change requests</h1> | |
| 370 | 425 | <p class="muted">{{.Open}} open · {{.Total}} total</p> | |
| 426 | {{if .CanOpen}} | ||
| 427 | <form method="post" action="/r/{{.Name}}/requests" class="card"> | ||
| 428 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 429 | <label>Title <input type="text" name="title" required maxlength="200"></label> | ||
| 430 | <div class="row"> | ||
| 431 | <div><label>Base <select name="base">{{range .BaseBranches}}<option value="{{.}}"{{if eq . $.Base}} selected{{end}}>{{.}}</option>{{end}}</select></label></div> | ||
| 432 | <div><label>Head <select name="head">{{range .HeadBranches}}<option value="{{.}}">{{.}}</option>{{end}}</select></label></div> | ||
| 433 | </div> | ||
| 434 | <label>Description <textarea name="body" placeholder="Why should this land?"></textarea></label> | ||
| 435 | <button type="submit">Open change request</button> | ||
| 436 | {{if not .HeadBranches}}<p class="muted">Push a branch first. Contributors can push only under <code>refs/heads/contrib/<account-id>/</code>.</p>{{end}} | ||
| 437 | </form> | ||
| 438 | {{end}} | ||
| 371 | 439 | {{if .Rows}} | |
| 372 | 440 | <table> | |
| 373 | 441 | <tr><th>ID</th><th>Title</th><th>Base</th><th>Head</th><th>Approvals</th><th>State</th><th>Age</th></tr> | |
| @@ -421,6 +489,13 @@ commit to land, plus <code>approvals/<account-id>.md</code> from each appr | |||
| 421 | 489 | {{end}} | |
| 422 | 490 | ||
| 423 | 491 | <h2>Approvals</h2> | |
| 492 | {{if .CanApprove}} | ||
| 493 | <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/approve" class="card"> | ||
| 494 | <input type="hidden" name="csrf" value="{{.CSRF}}"> | ||
| 495 | <p class="muted">Approving names the current head <span class="sha">{{short .Request.Head}}</span>; a new head starts over.</p> | ||
| 496 | <button type="submit">Approve this head</button> | ||
| 497 | </form> | ||
| 498 | {{end}} | ||
| 424 | 499 | {{if .Required}}<p class="muted">{{.Current}} of {{.Required}} required approval(s).</p> | |
| 425 | 500 | {{else}}<p class="muted">This repository requires no approvals: only an admin may push.</p> | |
| 426 | 501 | {{end}} | |
| @@ -584,6 +659,103 @@ release a description; the tarball below works either way.</p> | |||
| 584 | 659 | {{template "footer" .}} | |
| 585 | 660 | {{end}} | |
| 586 | 661 | ||
| 662 | |||
| 663 | {{define "login"}} | ||
| 664 | {{template "header" .}} | ||
| 665 | <div class="auth card"> | ||
| 666 | <h1>Log in</h1> | ||
| 667 | {{if not .Enabled}} | ||
| 668 | <p class="muted">{{.Reason}}</p> | ||
| 669 | {{else}} | ||
| 670 | <form data-auth="login" data-next="{{.Next}}"> | ||
| 671 | <label>Account name | ||
| 672 | <input type="text" name="username" autocomplete="username webauthn" required autofocus> | ||
| 673 | </label> | ||
| 674 | <button type="submit">Use a passkey</button> | ||
| 675 | <p class="error" data-auth-error></p> | ||
| 676 | </form> | ||
| 677 | <p class="muted">No account yet? Ask an administrator for a one-time enrollment link.</p> | ||
| 678 | {{end}} | ||
| 679 | </div> | ||
| 680 | {{template "footer" .}} | ||
| 681 | {{end}} | ||
| 682 | |||
| 683 | {{define "invite"}} | ||
| 684 | {{template "header" .}} | ||
| 685 | <div class="auth card"> | ||
| 686 | {{if .Account}} | ||
| 687 | <h1>Join {{.Account.Name}}</h1> | ||
| 688 | {{if not .Enabled}} | ||
| 689 | <p class="muted">{{.Reason}}</p> | ||
| 690 | {{else if .Token}} | ||
| 691 | <p class="muted">Register this device as a passkey for <strong>{{.Account.Name}}</strong>. The link works once.</p> | ||
| 692 | <form data-auth="invite" data-token="{{.Token}}"> | ||
| 693 | <button type="submit">Create a passkey</button> | ||
| 694 | <p class="error" data-auth-error></p> | ||
| 695 | </form> | ||
| 696 | {{else}} | ||
| 697 | <p class="error">This enrollment link is no longer valid.</p> | ||
| 698 | {{end}} | ||
| 699 | {{else}} | ||
| 700 | <h1>Enrollment link</h1> | ||
| 701 | <p class="error">This enrollment link is invalid or has expired. Ask an administrator for a new one.</p> | ||
| 702 | {{end}} | ||
| 703 | </div> | ||
| 704 | {{template "footer" .}} | ||
| 705 | {{end}} | ||
| 706 | |||
| 707 | {{define "account"}} | ||
| 708 | {{template "header" .}} | ||
| 709 | <h1>Account</h1> | ||
| 710 | <p><strong>{{.Account.Name}}</strong> <span class="muted">{{.Account.ID}}</span></p> | ||
| 711 | |||
| 712 | <h2>Passkeys</h2> | ||
| 713 | {{if .Credentials}} | ||
| 714 | <table> | ||
| 715 | <tr><th>Credential</th><th>Label</th><th>Created</th><th>Last used</th></tr> | ||
| 716 | {{range .Credentials}} | ||
| 717 | <tr> | ||
| 718 | <td class="sha">{{credid .ID}}</td> | ||
| 719 | <td>{{if .Label}}{{.Label}}{{else}}passkey{{end}}</td> | ||
| 720 | <td class="muted">{{since .Created.Unix}}</td> | ||
| 721 | <td class="muted">{{if .LastUsed.IsZero}}never{{else}}{{since .LastUsed.Unix}}{{end}}</td> | ||
| 722 | </tr> | ||
| 723 | {{end}} | ||
| 724 | </table> | ||
| 725 | {{else}} | ||
| 726 | <p class="muted">No passkeys yet.</p> | ||
| 727 | {{end}} | ||
| 728 | |||
| 729 | {{if .Enabled}} | ||
| 730 | <h2>Add a passkey</h2> | ||
| 731 | <form data-auth="passkey"> | ||
| 732 | <label>Label <input type="text" name="label" placeholder="laptop, phone, security key"></label> | ||
| 733 | <button type="submit">Register this device</button> | ||
| 734 | <p class="error" data-auth-error></p> | ||
| 735 | </form> | ||
| 736 | {{else}} | ||
| 737 | <p class="muted">{{.Reason}}</p> | ||
| 738 | {{end}} | ||
| 739 | |||
| 740 | <h2>Sessions</h2> | ||
| 741 | {{if .Sessions}} | ||
| 742 | <table> | ||
| 743 | <tr><th>Created</th><th>Last seen</th><th>Expires</th><th>Client</th></tr> | ||
| 744 | {{range .Sessions}} | ||
| 745 | <tr> | ||
| 746 | <td class="muted">{{since .Created.Unix}}</td> | ||
| 747 | <td class="muted">{{since .LastSeen.Unix}}</td> | ||
| 748 | <td class="muted">{{.Expires.Format "2006-01-02 15:04"}}</td> | ||
| 749 | <td class="muted">{{.UserAgent}}</td> | ||
| 750 | </tr> | ||
| 751 | {{end}} | ||
| 752 | </table> | ||
| 753 | {{else}} | ||
| 754 | <p class="muted">No sessions.</p> | ||
| 755 | {{end}} | ||
| 756 | {{template "footer" .}} | ||
| 757 | {{end}} | ||
| 758 | |||
| 587 | 759 | {{define "runners"}} | |
| 588 | 760 | {{template "header" .}} | |
| 589 | 761 | <h1>Runners</h1> | |