vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 1: a working git remote with access control
653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b
Phase 1: a working git remote with access control Vole is now a functioning forge foundation: one CGO-free binary that serves real git over smart HTTP and enforces per-repo policy in pre-receive. What is in: - internal/transport drives `git http-backend` as a CGI subprocess. Chunked uploads are spooled to learn CONTENT_LENGTH (git's default client config produces them), spool files are cleaned on error and abort, and a janitor sweeps anything a crash left behind. Pushes are capped by size and serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS for a single 1 GiB push. - internal/acl stores roles on refs/vole/acl inside each repository, so access rules travel with a clone. One evaluation function is shared by the hook and the web layer. - internal/hook enforces policy in pre-receive: authentication, write role, protected refs, per-blob size limits, and a refusal to accept pushes to refs/vole/* - without that last check a writer could push their own acl.json and grant themselves admin. - internal/auth holds users, personal access tokens and web sessions in SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and are stored as SHA-256 hashes, since there is no low-entropy secret to grind. - internal/index is the derived read model, rebuildable from disk and refs. - internal/repo owns the bare-repo store and the metadata ref CAS helper. - internal/web is the browse surface: repo list, tree, blob, raw, history, login, and per-repo settings for access rules, rename and delete. - internal/signing creates the instance ed25519 key at first boot for the Phase 7 work. Every Phase 1 exit criterion has a test that would fail if it regressed: clone/push against a real listener, read-only and anonymous rejection with the ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a spool file proven absent after an aborted chunked upload, and two pushes completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet, build, a markdown link check and the race-enabled suite. Verified by hand as well: a clean `vole init` -> user -> token -> push -> clone cycle, web login and browse, and a 256 MiB chunked push killed mid-flight leaving no ref, no spool file and no orphaned git processes.
internal/web/templates.go +280
| @@ -0,0 +1,280 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | const templates = ` | ||
| 4 | {{define "header"}}<!doctype html> | ||
| 5 | <html lang="en"> | ||
| 6 | <head> | ||
| 7 | <meta charset="utf-8"> | ||
| 8 | <meta name="viewport" content="width=device-width,initial-scale=1"> | ||
| 9 | <title>{{.Title}} · Vole</title> | ||
| 10 | <style> | ||
| 11 | :root{ | ||
| 12 | --bg:#0f1115; --fg:#e6e6e6; --muted:#8b93a1; --line:#242a35; | ||
| 13 | --accent:#6ea8fe; --card:#161a21; --ok:#3fb950; --bad:#f85149; | ||
| 14 | } | ||
| 15 | *{box-sizing:border-box} | ||
| 16 | body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif} | ||
| 17 | a{color:var(--accent);text-decoration:none} | ||
| 18 | a:hover{text-decoration:underline} | ||
| 19 | header{display:flex;align-items:center;gap:1rem;padding:.9rem 1.25rem;border-bottom:1px solid var(--line);background:var(--card)} | ||
| 20 | .brand{font-weight:700;letter-spacing:.02em;color:var(--fg)} | ||
| 21 | header nav{margin-left:auto;display:flex;gap:.85rem;align-items:center;color:var(--muted)} | ||
| 22 | main{max-width:62rem;margin:0 auto;padding:1.5rem 1.25rem 4rem} | ||
| 23 | h1{font-size:1.45rem;margin:.2rem 0 1rem} | ||
| 24 | h2{font-size:1.05rem;margin:1.75rem 0 .6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em} | ||
| 25 | .muted{color:var(--muted)} | ||
| 26 | .flash,.error{border-radius:6px;padding:.6rem .8rem;margin:0 0 1rem;font-size:.92rem} | ||
| 27 | .flash{background:rgba(63,185,80,.12);border:1px solid rgba(63,185,80,.35)} | ||
| 28 | .error{background:rgba(248,81,73,.12);border:1px solid rgba(248,81,73,.35)} | ||
| 29 | table{width:100%;border-collapse:collapse;font-size:.93rem} | ||
| 30 | th,td{text-align:left;padding:.45rem .5rem;border-bottom:1px solid var(--line);vertical-align:top} | ||
| 31 | th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em} | ||
| 32 | code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} | ||
| 33 | code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em} | ||
| 34 | pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow:auto;font-size:.86rem;line-height:1.5} | ||
| 35 | form.inline{display:inline} | ||
| 36 | button{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.35rem .75rem;font:inherit;font-size:.88rem;cursor:pointer} | ||
| 37 | button:hover{border-color:var(--accent)} | ||
| 38 | button.danger{border-color:rgba(248,81,73,.5);color:var(--bad)} | ||
| 39 | input,select,textarea{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.4rem .55rem;font:inherit;font-size:.9rem} | ||
| 40 | input[type=password],input[type=text]{min-width:16rem} | ||
| 41 | textarea{width:100%;min-height:5rem;font-family:ui-monospace,monospace} | ||
| 42 | label{display:block;margin:.55rem 0 .2rem;color:var(--muted);font-size:.85rem} | ||
| 43 | .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0} | ||
| 44 | .row{display:flex;gap:1.5rem;flex-wrap:wrap} | ||
| 45 | .row>div{flex:1 1 18rem} | ||
| 46 | .crumbs{color:var(--muted);font-size:.9rem;margin-bottom:.75rem} | ||
| 47 | .dir{color:var(--accent)} | ||
| 48 | .sha{font-family:ui-monospace,monospace;font-size:.85rem;color:var(--muted)} | ||
| 49 | </style> | ||
| 50 | </head> | ||
| 51 | <body> | ||
| 52 | <header> | ||
| 53 | <a class="brand" href="/">vole</a> | ||
| 54 | <nav> | ||
| 55 | {{with .User}} | ||
| 56 | <span class="muted">{{.Username}}</span> | ||
| 57 | <form method="post" action="/logout" class="inline"><button>sign out</button></form> | ||
| 58 | {{end}} | ||
| 59 | </nav> | ||
| 60 | </header> | ||
| 61 | <main> | ||
| 62 | {{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}} | ||
| 63 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} | ||
| 64 | {{end}} | ||
| 65 | |||
| 66 | {{define "footer"}} | ||
| 67 | </main> | ||
| 68 | </body> | ||
| 69 | </html> | ||
| 70 | {{end}} | ||
| 71 | |||
| 72 | {{define "login"}} | ||
| 73 | {{template "header" .}} | ||
| 74 | <h1>Sign in</h1> | ||
| 75 | <form method="post" action="/login" class="card"> | ||
| 76 | <input type="hidden" name="next" value="{{.Next}}"> | ||
| 77 | <label for="u">Username</label> | ||
| 78 | <input id="u" name="username" type="text" autocomplete="username" autofocus> | ||
| 79 | <label for="p">Password</label> | ||
| 80 | <input id="p" name="password" type="password" autocomplete="current-password"> | ||
| 81 | <p><button type="submit">Sign in</button></p> | ||
| 82 | </form> | ||
| 83 | {{template "footer" .}} | ||
| 84 | {{end}} | ||
| 85 | |||
| 86 | {{define "index"}} | ||
| 87 | {{template "header" .}} | ||
| 88 | <h1>Repositories</h1> | ||
| 89 | {{if .Repos}} | ||
| 90 | <table> | ||
| 91 | <tr><th>Name</th><th>Description</th><th>Access</th><th>Created</th></tr> | ||
| 92 | {{range .Repos}} | ||
| 93 | <tr> | ||
| 94 | <td><a href="/r/{{.Name}}">{{.Name}}</a></td> | ||
| 95 | <td class="muted">{{.Description}}</td> | ||
| 96 | <td class="muted">{{.ACLDefault}} default · {{.ACLPrincipals}} principal(s)</td> | ||
| 97 | <td class="muted">{{since .Created.Unix}}</td> | ||
| 98 | </tr> | ||
| 99 | {{end}} | ||
| 100 | </table> | ||
| 101 | {{else}} | ||
| 102 | <p class="muted">No repositories yet.</p> | ||
| 103 | {{end}} | ||
| 104 | |||
| 105 | {{if .Admin}} | ||
| 106 | <h2>Create a repository</h2> | ||
| 107 | <form method="post" action="/r/new" class="card"> | ||
| 108 | <label for="n">Name</label> | ||
| 109 | <input id="n" name="name" type="text" placeholder="lowercase-with-dashes" required> | ||
| 110 | <label for="d">Description</label> | ||
| 111 | <input id="d" name="description" type="text"> | ||
| 112 | <p><button type="submit">Create</button></p> | ||
| 113 | </form> | ||
| 114 | {{end}} | ||
| 115 | {{template "footer" .}} | ||
| 116 | {{end}} | ||
| 117 | |||
| 118 | {{define "repo"}} | ||
| 119 | {{template "header" .}} | ||
| 120 | <h1>{{.Name}}</h1> | ||
| 121 | {{if .Description}}<p class="muted">{{.Description}}</p>{{end}} | ||
| 122 | |||
| 123 | <div class="row"> | ||
| 124 | <div> | ||
| 125 | <h2>Clone</h2> | ||
| 126 | <p><code>{{.CloneURL}}</code></p> | ||
| 127 | <p class="muted">Your password is your personal access token.</p> | ||
| 128 | </div> | ||
| 129 | <div> | ||
| 130 | <h2>Branch</h2> | ||
| 131 | <form method="get" action="/r/{{.Name}}"> | ||
| 132 | <select name="ref" onchange="this.form.submit()"> | ||
| 133 | {{range .Branches}}<option value="{{.}}"{{if eq . $.Ref}} selected{{end}}>{{.}}</option>{{end}} | ||
| 134 | </select> | ||
| 135 | </form> | ||
| 136 | <p class="muted">{{.Count}} commit(s) · head <span class="sha">{{short .Commit}}</span></p> | ||
| 137 | </div> | ||
| 138 | </div> | ||
| 139 | |||
| 140 | {{if .RepoAdmin}}<p><a href="/r/{{.Name}}/settings">Repository settings</a></p>{{end}} | ||
| 141 | |||
| 142 | <h2>Files</h2> | ||
| 143 | {{if .Entries}} | ||
| 144 | <table> | ||
| 145 | {{range .Entries}} | ||
| 146 | <tr> | ||
| 147 | {{if eq .Type "tree"}} | ||
| 148 | <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td> | ||
| 149 | {{else}} | ||
| 150 | <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td> | ||
| 151 | {{end}} | ||
| 152 | <td class="muted">{{.Mode}}</td> | ||
| 153 | </tr> | ||
| 154 | {{end}} | ||
| 155 | </table> | ||
| 156 | {{else}} | ||
| 157 | <p class="muted">This repository is empty. Push something.</p> | ||
| 158 | {{end}} | ||
| 159 | |||
| 160 | <h2>Recent commits</h2> | ||
| 161 | {{template "commitlist" .}} | ||
| 162 | {{template "footer" .}} | ||
| 163 | {{end}} | ||
| 164 | |||
| 165 | {{define "commitlist"}} | ||
| 166 | {{if .Commits}} | ||
| 167 | <table> | ||
| 168 | {{range .Commits}} | ||
| 169 | <tr> | ||
| 170 | <td><span class="sha">{{short .SHA}}</span></td> | ||
| 171 | <td>{{.Subject}}</td> | ||
| 172 | <td class="muted">{{.Author}}</td> | ||
| 173 | <td class="muted">{{since .When}}</td> | ||
| 174 | </tr> | ||
| 175 | {{end}} | ||
| 176 | </table> | ||
| 177 | {{else}} | ||
| 178 | <p class="muted">No commits yet.</p> | ||
| 179 | {{end}} | ||
| 180 | {{end}} | ||
| 181 | |||
| 182 | {{define "tree"}} | ||
| 183 | {{template "header" .}} | ||
| 184 | <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1> | ||
| 185 | <p class="crumbs"> | ||
| 186 | <a href="/r/{{.Name}}/tree/{{esc .Ref}}">{{.Ref}}</a> | ||
| 187 | {{range .Crumbs}} / <span>{{.Name}}</span>{{end}} | ||
| 188 | </p> | ||
| 189 | <table> | ||
| 190 | {{range .Entries}} | ||
| 191 | <tr> | ||
| 192 | {{if eq .Type "tree"}} | ||
| 193 | <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td> | ||
| 194 | {{else}} | ||
| 195 | <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td> | ||
| 196 | {{end}} | ||
| 197 | <td class="muted">{{.Mode}}</td> | ||
| 198 | </tr> | ||
| 199 | {{end}} | ||
| 200 | </table> | ||
| 201 | {{template "footer" .}} | ||
| 202 | {{end}} | ||
| 203 | |||
| 204 | {{define "blob"}} | ||
| 205 | {{template "header" .}} | ||
| 206 | <h1><a href="/r/{{.Name}}">{{.Name}}</a></h1> | ||
| 207 | <p class="crumbs"> | ||
| 208 | <a href="/r/{{.Name}}/blob/{{esc .Ref}}">{{.Path}}</a> · | ||
| 209 | <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">raw</a> · | ||
| 210 | <span class="muted">{{.Size}} bytes</span> | ||
| 211 | </p> | ||
| 212 | {{if .Binary}} | ||
| 213 | <p class="muted">Binary file, {{.Size}} bytes. <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">Download it</a>.</p> | ||
| 214 | {{else}} | ||
| 215 | {{if .Truncated}}<p class="muted">Showing the first 1 MiB.</p>{{end}} | ||
| 216 | <pre>{{.Content}}</pre> | ||
| 217 | {{end}} | ||
| 218 | {{template "footer" .}} | ||
| 219 | {{end}} | ||
| 220 | |||
| 221 | {{define "commits"}} | ||
| 222 | {{template "header" .}} | ||
| 223 | <h1><a href="/r/{{.Name}}">{{.Name}}</a> · history</h1> | ||
| 224 | <p class="crumbs"><span class="muted">{{.Ref}}</span></p> | ||
| 225 | {{template "commitlist" .}} | ||
| 226 | {{template "footer" .}} | ||
| 227 | {{end}} | ||
| 228 | |||
| 229 | {{define "settings"}} | ||
| 230 | {{template "header" .}} | ||
| 231 | <h1><a href="/r/{{.Name}}">{{.Name}}</a> · settings</h1> | ||
| 232 | |||
| 233 | <h2>Access</h2> | ||
| 234 | <form method="post" action="/r/{{.Name}}/acl" class="card"> | ||
| 235 | <table> | ||
| 236 | <tr><th>Principal</th><th>Role</th></tr> | ||
| 237 | <tr> | ||
| 238 | <td><em>default</em></td> | ||
| 239 | <td> | ||
| 240 | <select name="default"> | ||
| 241 | {{range $r := list "none" "read" "write" "admin"}} | ||
| 242 | <option value="{{$r}}"{{if eq $r $.ACL.Default}} selected{{end}}>{{$r}}</option> | ||
| 243 | {{end}} | ||
| 244 | </select> | ||
| 245 | </td> | ||
| 246 | </tr> | ||
| 247 | {{range .Users}} | ||
| 248 | <tr> | ||
| 249 | <td>{{.}}</td> | ||
| 250 | <td> | ||
| 251 | <select name="role_{{.}}"> | ||
| 252 | {{range $r := list "none" "read" "write" "admin"}} | ||
| 253 | <option value="{{$r}}"{{if eq $r (index $.ACL.Entries .)}} selected{{end}}>{{$r}}</option> | ||
| 254 | {{end}} | ||
| 255 | </select> | ||
| 256 | </td> | ||
| 257 | </tr> | ||
| 258 | {{end}} | ||
| 259 | </table> | ||
| 260 | <label for="prot">Protected refs (one per line, trailing * allowed)</label> | ||
| 261 | <textarea id="prot" name="protected" placeholder="refs/heads/main">{{join .ACL.Protected}}</textarea> | ||
| 262 | <p><button type="submit">Save access rules</button></p> | ||
| 263 | </form> | ||
| 264 | |||
| 265 | <h2>Rename</h2> | ||
| 266 | <form method="post" action="/r/{{.Name}}/rename" class="card"> | ||
| 267 | <label for="rn">New name</label> | ||
| 268 | <input id="rn" name="name" type="text" value="{{.Name}}"> | ||
| 269 | <p><button type="submit">Rename</button></p> | ||
| 270 | </form> | ||
| 271 | |||
| 272 | <h2>Delete</h2> | ||
| 273 | <form method="post" action="/r/{{.Name}}/delete" class="card"> | ||
| 274 | <label for="cf">Type <code>{{.Name}}</code> to confirm</label> | ||
| 275 | <input id="cf" name="confirm" type="text" autocomplete="off"> | ||
| 276 | <p><button class="danger" type="submit">Delete repository</button></p> | ||
| 277 | </form> | ||
| 278 | {{template "footer" .}} | ||
| 279 | {{end}} | ||
| 280 | ` | ||