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/web.go +647
| @@ -0,0 +1,647 @@ | |||
| 1 | // Package web is Vole's HTML surface: repo list, browse, settings and login. | ||
| 2 | // Read paths all go through git, never through the derived index, so what you | ||
| 3 | // see is what is actually committed. | ||
| 4 | package web | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "fmt" | ||
| 8 | "html/template" | ||
| 9 | "net/http" | ||
| 10 | "net/url" | ||
| 11 | "strings" | ||
| 12 | "time" | ||
| 13 | "unicode/utf8" | ||
| 14 | |||
| 15 | "github.com/rodent-software/vole/internal/acl" | ||
| 16 | "github.com/rodent-software/vole/internal/auth" | ||
| 17 | "github.com/rodent-software/vole/internal/config" | ||
| 18 | "github.com/rodent-software/vole/internal/gitx" | ||
| 19 | "github.com/rodent-software/vole/internal/index" | ||
| 20 | "github.com/rodent-software/vole/internal/repo" | ||
| 21 | ) | ||
| 22 | |||
| 23 | const SessionCookie = "vole_session" | ||
| 24 | |||
| 25 | type Web struct { | ||
| 26 | Repos *repo.Store | ||
| 27 | Auth *auth.Store | ||
| 28 | Index *index.Store | ||
| 29 | Cfg *config.Config | ||
| 30 | Tmpl *template.Template | ||
| 31 | Logger func(format string, args ...any) | ||
| 32 | } | ||
| 33 | |||
| 34 | func New(repos *repo.Store, a *auth.Store, ix *index.Store, cfg *config.Config) (*Web, error) { | ||
| 35 | t, err := template.New("vole").Funcs(template.FuncMap{ | ||
| 36 | "since": humanSince, | ||
| 37 | "short": shortSHA, | ||
| 38 | "esc": url.PathEscape, | ||
| 39 | "join": func(v []string) string { return strings.Join(v, "\n") }, | ||
| 40 | "list": func(v ...string) []string { return v }, | ||
| 41 | }).Parse(templates) | ||
| 42 | if err != nil { | ||
| 43 | return nil, err | ||
| 44 | } | ||
| 45 | w := &Web{Repos: repos, Auth: a, Index: ix, Cfg: cfg, Tmpl: t} | ||
| 46 | w.Logger = func(string, ...any) {} | ||
| 47 | return w, nil | ||
| 48 | } | ||
| 49 | |||
| 50 | // ---------- request context helpers ---------- | ||
| 51 | |||
| 52 | func (w *Web) user(r *http.Request) *auth.User { | ||
| 53 | c, err := r.Cookie(SessionCookie) | ||
| 54 | if err != nil { | ||
| 55 | return nil | ||
| 56 | } | ||
| 57 | u, err := w.Auth.SessionUser(c.Value) | ||
| 58 | if err != nil { | ||
| 59 | return nil | ||
| 60 | } | ||
| 61 | return u | ||
| 62 | } | ||
| 63 | |||
| 64 | func (w *Web) IsInstanceAdmin(u *auth.User) bool { | ||
| 65 | if u == nil { | ||
| 66 | return false | ||
| 67 | } | ||
| 68 | if w.Cfg.IsAdmin(u.Username) { | ||
| 69 | return true | ||
| 70 | } | ||
| 71 | // Bootstrap rule: on a single-user instance that user is the admin, so a | ||
| 72 | // fresh install is usable without hand-editing config. | ||
| 73 | if n, err := w.Auth.CountUsers(); err == nil && n == 1 { | ||
| 74 | return true | ||
| 75 | } | ||
| 76 | return false | ||
| 77 | } | ||
| 78 | |||
| 79 | func (w *Web) roleFor(m *repo.Meta, u *auth.User) acl.Role { | ||
| 80 | a, err := m.ACL() | ||
| 81 | if err != nil { | ||
| 82 | return acl.None | ||
| 83 | } | ||
| 84 | if u == nil { | ||
| 85 | return a.RoleFor("") | ||
| 86 | } | ||
| 87 | return a.RoleFor(u.Username) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Page is exported because html/template cannot evaluate promoted fields | ||
| 91 | // through an unexported embedded type. | ||
| 92 | type Page struct { | ||
| 93 | Title string | ||
| 94 | User *auth.User | ||
| 95 | Admin bool | ||
| 96 | Flash string | ||
| 97 | Error string | ||
| 98 | } | ||
| 99 | |||
| 100 | func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) { | ||
| 101 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| 102 | if status != 0 { | ||
| 103 | rw.WriteHeader(status) | ||
| 104 | } | ||
| 105 | if err := w.Tmpl.ExecuteTemplate(rw, name, data); err != nil { | ||
| 106 | w.Logger("render %s: %v", name, err) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | func (w *Web) base(r *http.Request, title string) Page { | ||
| 111 | u := w.user(r) | ||
| 112 | p := Page{Title: title, User: u, Admin: w.IsInstanceAdmin(u)} | ||
| 113 | if f := r.URL.Query().Get("ok"); f != "" { | ||
| 114 | p.Flash = f | ||
| 115 | } | ||
| 116 | if e := r.URL.Query().Get("err"); e != "" { | ||
| 117 | p.Error = e | ||
| 118 | } | ||
| 119 | return p | ||
| 120 | } | ||
| 121 | |||
| 122 | // requireUser redirects to the login page when unauthenticated. | ||
| 123 | func (w *Web) requireUser(rw http.ResponseWriter, r *http.Request) *auth.User { | ||
| 124 | u := w.user(r) | ||
| 125 | if u == nil { | ||
| 126 | next := url.QueryEscape(r.URL.RequestURI()) | ||
| 127 | http.Redirect(rw, r, "/login?next="+next, http.StatusSeeOther) | ||
| 128 | return nil | ||
| 129 | } | ||
| 130 | return u | ||
| 131 | } | ||
| 132 | |||
| 133 | // ---------- handlers ---------- | ||
| 134 | |||
| 135 | func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | ||
| 136 | u := w.requireUser(rw, r) | ||
| 137 | if u == nil { | ||
| 138 | return | ||
| 139 | } | ||
| 140 | repos, err := w.Index.List() | ||
| 141 | if err != nil { | ||
| 142 | http.Error(rw, "index unavailable: "+err.Error(), 500) | ||
| 143 | return | ||
| 144 | } | ||
| 145 | // Only show repos the user can read. | ||
| 146 | var visible []index.Entry | ||
| 147 | for _, e := range repos { | ||
| 148 | m, err := w.Repos.Open(e.Name) | ||
| 149 | if err != nil { | ||
| 150 | continue | ||
| 151 | } | ||
| 152 | if w.roleFor(m, u).AtLeast(acl.Read) { | ||
| 153 | visible = append(visible, e) | ||
| 154 | } | ||
| 155 | } | ||
| 156 | w.render(rw, "index", struct { | ||
| 157 | Page | ||
| 158 | Repos []index.Entry | ||
| 159 | }{w.base(r, "Repositories"), visible}, 0) | ||
| 160 | } | ||
| 161 | |||
| 162 | func (w *Web) NewRepo(rw http.ResponseWriter, r *http.Request) { | ||
| 163 | u := w.requireUser(rw, r) | ||
| 164 | if u == nil { | ||
| 165 | return | ||
| 166 | } | ||
| 167 | if !w.IsInstanceAdmin(u) { | ||
| 168 | http.Error(rw, "only instance admins can create repositories", http.StatusForbidden) | ||
| 169 | return | ||
| 170 | } | ||
| 171 | name := strings.TrimSpace(r.FormValue("name")) | ||
| 172 | desc := strings.TrimSpace(r.FormValue("description")) | ||
| 173 | m, err := w.Repos.Create(name, u.Username, desc) | ||
| 174 | if err != nil { | ||
| 175 | http.Redirect(rw, r, "/?err="+url.QueryEscape(err.Error()), http.StatusSeeOther) | ||
| 176 | return | ||
| 177 | } | ||
| 178 | if err := w.Index.Upsert(m); err != nil { | ||
| 179 | w.Logger("index upsert %s: %v", name, err) | ||
| 180 | } | ||
| 181 | http.Redirect(rw, r, "/r/"+url.PathEscape(m.Name)+"?ok="+url.QueryEscape("repository created"), http.StatusSeeOther) | ||
| 182 | } | ||
| 183 | |||
| 184 | func (w *Web) repo(rw http.ResponseWriter, r *http.Request) (*repo.Meta, *auth.User, acl.Role, bool) { | ||
| 185 | u := w.requireUser(rw, r) | ||
| 186 | if u == nil { | ||
| 187 | return nil, nil, acl.None, false | ||
| 188 | } | ||
| 189 | name := r.PathValue("name") | ||
| 190 | m, err := w.Repos.Open(name) | ||
| 191 | if err != nil { | ||
| 192 | http.NotFound(rw, r) | ||
| 193 | return nil, nil, acl.None, false | ||
| 194 | } | ||
| 195 | role := w.roleFor(m, u) | ||
| 196 | if !role.AtLeast(acl.Read) { | ||
| 197 | http.Error(rw, fmt.Sprintf("you do not have read access to %s", name), http.StatusForbidden) | ||
| 198 | return nil, nil, acl.None, false | ||
| 199 | } | ||
| 200 | return m, u, role, true | ||
| 201 | } | ||
| 202 | |||
| 203 | func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | ||
| 204 | m, _, role, ok := w.repo(rw, r) | ||
| 205 | if !ok { | ||
| 206 | return | ||
| 207 | } | ||
| 208 | g := m.Git() | ||
| 209 | ref := r.URL.Query().Get("ref") | ||
| 210 | if ref == "" { | ||
| 211 | ref = m.DefaultBranch | ||
| 212 | } | ||
| 213 | commit, _ := g.RevParse(ref) | ||
| 214 | var entries []gitx.Entry | ||
| 215 | var commits []gitx.Commit | ||
| 216 | count := 0 | ||
| 217 | if commit != "" { | ||
| 218 | entries, _ = g.LsTree(commit) | ||
| 219 | commits, _ = g.Log(commit, 10) | ||
| 220 | count, _ = g.CountCommits(commit) | ||
| 221 | } | ||
| 222 | branches, _ := g.ListRefs("refs/heads/") | ||
| 223 | names := make([]string, 0, len(branches)) | ||
| 224 | for k := range branches { | ||
| 225 | names = append(names, strings.TrimPrefix(k, "refs/heads/")) | ||
| 226 | } | ||
| 227 | w.render(rw, "repo", struct { | ||
| 228 | Page | ||
| 229 | Name string | ||
| 230 | Description string | ||
| 231 | Ref string | ||
| 232 | Role acl.Role | ||
| 233 | RepoAdmin bool | ||
| 234 | CloneURL string | ||
| 235 | Entries []gitx.Entry | ||
| 236 | Commits []gitx.Commit | ||
| 237 | Commit string | ||
| 238 | Count int | ||
| 239 | Branches []string | ||
| 240 | }{ | ||
| 241 | Page: w.base(r, m.Name), | ||
| 242 | Name: m.Name, | ||
| 243 | Description: m.Description, | ||
| 244 | Ref: ref, | ||
| 245 | Role: role, | ||
| 246 | RepoAdmin: role.AtLeast(acl.Admin), | ||
| 247 | CloneURL: cloneURL(r, m.Name), | ||
| 248 | Entries: entries, | ||
| 249 | Commits: commits, | ||
| 250 | Commit: commit, | ||
| 251 | Count: count, | ||
| 252 | Branches: names, | ||
| 253 | }, 0) | ||
| 254 | } | ||
| 255 | |||
| 256 | func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | ||
| 257 | m, _, _, ok := w.repo(rw, r) | ||
| 258 | if !ok { | ||
| 259 | return | ||
| 260 | } | ||
| 261 | ref, p := r.PathValue("ref"), r.PathValue("path") | ||
| 262 | if !safePath(p) { | ||
| 263 | http.Error(rw, "invalid path", http.StatusBadRequest) | ||
| 264 | return | ||
| 265 | } | ||
| 266 | g := m.Git() | ||
| 267 | commit, _ := g.RevParse(ref) | ||
| 268 | if commit == "" { | ||
| 269 | http.NotFound(rw, r) | ||
| 270 | return | ||
| 271 | } | ||
| 272 | treeish := commit | ||
| 273 | if p != "" { | ||
| 274 | treeish = commit + ":" + p | ||
| 275 | } | ||
| 276 | entries, err := g.LsTree(treeish) | ||
| 277 | if err != nil { | ||
| 278 | http.Error(rw, "not a directory: "+err.Error(), http.StatusBadRequest) | ||
| 279 | return | ||
| 280 | } | ||
| 281 | w.render(rw, "tree", struct { | ||
| 282 | Page | ||
| 283 | Name string | ||
| 284 | Ref string | ||
| 285 | Path string | ||
| 286 | Entries []gitx.Entry | ||
| 287 | Crumbs []crumb | ||
| 288 | }{w.base(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(ref, p)}, 0) | ||
| 289 | } | ||
| 290 | |||
| 291 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { | ||
| 292 | w.blob(rw, r, false) | ||
| 293 | } | ||
| 294 | |||
| 295 | func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { | ||
| 296 | w.blob(rw, r, true) | ||
| 297 | } | ||
| 298 | |||
| 299 | const maxInlineBytes = 1 << 20 | ||
| 300 | |||
| 301 | func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | ||
| 302 | m, _, _, ok := w.repo(rw, r) | ||
| 303 | if !ok { | ||
| 304 | return | ||
| 305 | } | ||
| 306 | ref, p := r.PathValue("ref"), r.PathValue("path") | ||
| 307 | if !safePath(p) { | ||
| 308 | http.Error(rw, "invalid path", http.StatusBadRequest) | ||
| 309 | return | ||
| 310 | } | ||
| 311 | g := m.Git() | ||
| 312 | commit, _ := g.RevParse(ref) | ||
| 313 | if commit == "" { | ||
| 314 | http.NotFound(rw, r) | ||
| 315 | return | ||
| 316 | } | ||
| 317 | data, err := g.BlobAt(commit, p) | ||
| 318 | if err != nil { | ||
| 319 | http.NotFound(rw, r) | ||
| 320 | return | ||
| 321 | } | ||
| 322 | if raw { | ||
| 323 | rw.Header().Set("Content-Type", "text/plain; charset=utf-8") | ||
| 324 | rw.Write(data) | ||
| 325 | return | ||
| 326 | } | ||
| 327 | content := string(data) | ||
| 328 | binary := !utf8.Valid(data) | ||
| 329 | truncated := false | ||
| 330 | if len(data) > maxInlineBytes { | ||
| 331 | content = string(data[:maxInlineBytes]) | ||
| 332 | truncated = true | ||
| 333 | } | ||
| 334 | w.render(rw, "blob", struct { | ||
| 335 | Page | ||
| 336 | Name string | ||
| 337 | Ref string | ||
| 338 | Path string | ||
| 339 | Content string | ||
| 340 | Binary bool | ||
| 341 | Truncated bool | ||
| 342 | Size int64 | ||
| 343 | Crumbs []crumb | ||
| 344 | }{w.base(r, m.Name+" · "+p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(ref, p)}, 0) | ||
| 345 | } | ||
| 346 | |||
| 347 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | ||
| 348 | m, _, _, ok := w.repo(rw, r) | ||
| 349 | if !ok { | ||
| 350 | return | ||
| 351 | } | ||
| 352 | ref := r.PathValue("ref") | ||
| 353 | g := m.Git() | ||
| 354 | commit, _ := g.RevParse(ref) | ||
| 355 | if commit == "" { | ||
| 356 | http.NotFound(rw, r) | ||
| 357 | return | ||
| 358 | } | ||
| 359 | commits, err := g.Log(commit, 100) | ||
| 360 | if err != nil { | ||
| 361 | http.Error(rw, err.Error(), 500) | ||
| 362 | return | ||
| 363 | } | ||
| 364 | w.render(rw, "commits", struct { | ||
| 365 | Page | ||
| 366 | Name string | ||
| 367 | Ref string | ||
| 368 | Commits []gitx.Commit | ||
| 369 | }{w.base(r, m.Name+" · history"), m.Name, ref, commits}, 0) | ||
| 370 | } | ||
| 371 | |||
| 372 | func (w *Web) Settings(rw http.ResponseWriter, r *http.Request) { | ||
| 373 | m, _, role, ok := w.repo(rw, r) | ||
| 374 | if !ok { | ||
| 375 | return | ||
| 376 | } | ||
| 377 | if !role.AtLeast(acl.Admin) { | ||
| 378 | http.Error(rw, "repository settings require admin access", http.StatusForbidden) | ||
| 379 | return | ||
| 380 | } | ||
| 381 | a, err := m.ACL() | ||
| 382 | if err != nil { | ||
| 383 | http.Error(rw, err.Error(), 500) | ||
| 384 | return | ||
| 385 | } | ||
| 386 | var all []string | ||
| 387 | if users, err := w.Auth.ListUsers(); err == nil { | ||
| 388 | for _, x := range users { | ||
| 389 | all = append(all, x.Username) | ||
| 390 | } | ||
| 391 | } | ||
| 392 | w.render(rw, "settings", struct { | ||
| 393 | Page | ||
| 394 | Name string | ||
| 395 | ACL *acl.ACL | ||
| 396 | Principals []string | ||
| 397 | Users []string | ||
| 398 | }{ | ||
| 399 | Page: w.base(r, m.Name+" · settings"), | ||
| 400 | Name: m.Name, | ||
| 401 | ACL: a, | ||
| 402 | Principals: a.Principals(), | ||
| 403 | Users: all, | ||
| 404 | }, 0) | ||
| 405 | } | ||
| 406 | |||
| 407 | func (w *Web) UpdateACL(rw http.ResponseWriter, r *http.Request) { | ||
| 408 | m, u, role, ok := w.repo(rw, r) | ||
| 409 | if !ok { | ||
| 410 | return | ||
| 411 | } | ||
| 412 | if !role.AtLeast(acl.Admin) { | ||
| 413 | http.Error(rw, "repository settings require admin access", http.StatusForbidden) | ||
| 414 | return | ||
| 415 | } | ||
| 416 | if err := r.ParseForm(); err != nil { | ||
| 417 | http.Error(rw, err.Error(), 400) | ||
| 418 | return | ||
| 419 | } | ||
| 420 | a, err := m.ACL() | ||
| 421 | if err != nil { | ||
| 422 | http.Error(rw, err.Error(), 500) | ||
| 423 | return | ||
| 424 | } | ||
| 425 | if d := r.FormValue("default"); d != "" { | ||
| 426 | if _, err := acl.ParseRole(d); err != nil { | ||
| 427 | http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther) | ||
| 428 | return | ||
| 429 | } | ||
| 430 | a.Default = strings.ToLower(d) | ||
| 431 | } | ||
| 432 | for k, vals := range r.Form { | ||
| 433 | if !strings.HasPrefix(k, "role_") || len(vals) == 0 { | ||
| 434 | continue | ||
| 435 | } | ||
| 436 | principal := strings.TrimPrefix(k, "role_") | ||
| 437 | if err := a.SetEntry(principal, vals[0]); err != nil { | ||
| 438 | http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther) | ||
| 439 | return | ||
| 440 | } | ||
| 441 | } | ||
| 442 | if prot := r.FormValue("protected"); prot != "" || r.Form.Has("protected") { | ||
| 443 | var lines []string | ||
| 444 | for _, line := range strings.Split(prot, "\n") { | ||
| 445 | if l := strings.TrimSpace(line); l != "" { | ||
| 446 | lines = append(lines, l) | ||
| 447 | } | ||
| 448 | } | ||
| 449 | a.Protected = lines | ||
| 450 | } | ||
| 451 | if err := m.SetACL(a, u.Username); err != nil { | ||
| 452 | http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther) | ||
| 453 | return | ||
| 454 | } | ||
| 455 | if err := w.Index.Upsert(m); err != nil { | ||
| 456 | w.Logger("index upsert: %v", err) | ||
| 457 | } | ||
| 458 | http.Redirect(rw, r, settingsURL(m.Name, ""), http.StatusSeeOther) | ||
| 459 | } | ||
| 460 | |||
| 461 | func (w *Web) RenameRepo(rw http.ResponseWriter, r *http.Request) { | ||
| 462 | m, u, role, ok := w.repo(rw, r) | ||
| 463 | if !ok { | ||
| 464 | return | ||
| 465 | } | ||
| 466 | if !role.AtLeast(acl.Admin) || !w.IsInstanceAdmin(u) { | ||
| 467 | http.Error(rw, "renaming requires repository admin and instance admin", http.StatusForbidden) | ||
| 468 | return | ||
| 469 | } | ||
| 470 | newName := strings.TrimSpace(r.FormValue("name")) | ||
| 471 | oldID := m.ID | ||
| 472 | if err := w.Repos.Rename(m.Name, newName); err != nil { | ||
| 473 | http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther) | ||
| 474 | return | ||
| 475 | } | ||
| 476 | _ = w.Index.Remove(oldID) | ||
| 477 | if nm, err := w.Repos.Open(newName); err == nil { | ||
| 478 | _ = w.Index.Upsert(nm) | ||
| 479 | } | ||
| 480 | http.Redirect(rw, r, "/r/"+url.PathEscape(newName)+"?ok="+url.QueryEscape("repository renamed"), http.StatusSeeOther) | ||
| 481 | } | ||
| 482 | |||
| 483 | func (w *Web) DeleteRepo(rw http.ResponseWriter, r *http.Request) { | ||
| 484 | m, u, role, ok := w.repo(rw, r) | ||
| 485 | if !ok { | ||
| 486 | return | ||
| 487 | } | ||
| 488 | if !role.AtLeast(acl.Admin) || !w.IsInstanceAdmin(u) { | ||
| 489 | http.Error(rw, "deleting requires repository admin and instance admin", http.StatusForbidden) | ||
| 490 | return | ||
| 491 | } | ||
| 492 | if r.FormValue("confirm") != m.Name { | ||
| 493 | http.Redirect(rw, r, settingsURL(m.Name, "type the repository name to confirm deletion"), http.StatusSeeOther) | ||
| 494 | return | ||
| 495 | } | ||
| 496 | if err := w.Repos.Delete(m.Name); err != nil { | ||
| 497 | http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther) | ||
| 498 | return | ||
| 499 | } | ||
| 500 | _ = w.Index.Remove(m.ID) | ||
| 501 | http.Redirect(rw, r, "/?ok="+url.QueryEscape("repository deleted"), http.StatusSeeOther) | ||
| 502 | } | ||
| 503 | |||
| 504 | // ---------- login ---------- | ||
| 505 | |||
| 506 | func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) { | ||
| 507 | if w.user(r) != nil { | ||
| 508 | http.Redirect(rw, r, "/", http.StatusSeeOther) | ||
| 509 | return | ||
| 510 | } | ||
| 511 | p := w.base(r, "Sign in") | ||
| 512 | w.render(rw, "login", struct { | ||
| 513 | Page | ||
| 514 | Next string | ||
| 515 | }{p, r.URL.Query().Get("next")}, 0) | ||
| 516 | } | ||
| 517 | |||
| 518 | func (w *Web) Login(rw http.ResponseWriter, r *http.Request) { | ||
| 519 | if err := r.ParseForm(); err != nil { | ||
| 520 | http.Error(rw, err.Error(), 400) | ||
| 521 | return | ||
| 522 | } | ||
| 523 | username := strings.TrimSpace(r.FormValue("username")) | ||
| 524 | password := r.FormValue("password") | ||
| 525 | next := r.FormValue("next") | ||
| 526 | if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { | ||
| 527 | next = "/" | ||
| 528 | } | ||
| 529 | u, err := w.Auth.Authenticate(username, password) | ||
| 530 | if err != nil { | ||
| 531 | p := w.base(r, "Sign in") | ||
| 532 | p.Error = "invalid username or password" | ||
| 533 | w.render(rw, "login", struct { | ||
| 534 | Page | ||
| 535 | Next string | ||
| 536 | }{p, next}, http.StatusUnauthorized) | ||
| 537 | return | ||
| 538 | } | ||
| 539 | sid, err := w.Auth.CreateSession(u.ID, w.Cfg.SessionTTLDur) | ||
| 540 | if err != nil { | ||
| 541 | http.Error(rw, "cannot create session: "+err.Error(), 500) | ||
| 542 | return | ||
| 543 | } | ||
| 544 | http.SetCookie(rw, &http.Cookie{ | ||
| 545 | Name: SessionCookie, | ||
| 546 | Value: sid, | ||
| 547 | Path: "/", | ||
| 548 | HttpOnly: true, | ||
| 549 | SameSite: http.SameSiteLaxMode, | ||
| 550 | Secure: r.TLS != nil, | ||
| 551 | Expires: time.Now().Add(w.Cfg.SessionTTLDur), | ||
| 552 | }) | ||
| 553 | http.Redirect(rw, r, next, http.StatusSeeOther) | ||
| 554 | } | ||
| 555 | |||
| 556 | func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) { | ||
| 557 | if c, err := r.Cookie(SessionCookie); err == nil { | ||
| 558 | _ = w.Auth.DeleteSession(c.Value) | ||
| 559 | } | ||
| 560 | http.SetCookie(rw, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) | ||
| 561 | http.Redirect(rw, r, "/login", http.StatusSeeOther) | ||
| 562 | } | ||
| 563 | |||
| 564 | // ---------- helpers ---------- | ||
| 565 | |||
| 566 | func cloneURL(r *http.Request, name string) string { | ||
| 567 | scheme := "http" | ||
| 568 | if r.TLS != nil { | ||
| 569 | scheme = "https" | ||
| 570 | } | ||
| 571 | if p := r.Header.Get("X-Forwarded-Proto"); p != "" { | ||
| 572 | scheme = p | ||
| 573 | } | ||
| 574 | host := r.Host | ||
| 575 | return fmt.Sprintf("%s://%s/git/%s.git", scheme, host, name) | ||
| 576 | } | ||
| 577 | |||
| 578 | func safePath(p string) bool { | ||
| 579 | if strings.Contains(p, "\x00") { | ||
| 580 | return false | ||
| 581 | } | ||
| 582 | for _, seg := range strings.Split(p, "/") { | ||
| 583 | if seg == ".." { | ||
| 584 | return false | ||
| 585 | } | ||
| 586 | } | ||
| 587 | return true | ||
| 588 | } | ||
| 589 | |||
| 590 | type crumb struct { | ||
| 591 | Name string | ||
| 592 | Path string | ||
| 593 | } | ||
| 594 | |||
| 595 | func crumbs(ref, p string) []crumb { | ||
| 596 | out := []crumb{{Name: strings.SplitN(ref, "/", 2)[0], Path: ""}} | ||
| 597 | cur := "" | ||
| 598 | parts := strings.Split(p, "/") | ||
| 599 | for i, seg := range parts { | ||
| 600 | if seg == "" { | ||
| 601 | continue | ||
| 602 | } | ||
| 603 | if cur == "" { | ||
| 604 | cur = seg | ||
| 605 | } else { | ||
| 606 | cur += "/" + seg | ||
| 607 | } | ||
| 608 | if i == len(parts)-1 { | ||
| 609 | out = append(out, crumb{Name: seg, Path: cur}) | ||
| 610 | } else { | ||
| 611 | out = append(out, crumb{Name: seg, Path: cur}) | ||
| 612 | } | ||
| 613 | } | ||
| 614 | return out | ||
| 615 | } | ||
| 616 | |||
| 617 | func settingsURL(name, errMsg string) string { | ||
| 618 | u := "/r/" + url.PathEscape(name) + "/settings" | ||
| 619 | if errMsg != "" { | ||
| 620 | u += "?err=" + url.QueryEscape(errMsg) | ||
| 621 | } | ||
| 622 | return u | ||
| 623 | } | ||
| 624 | |||
| 625 | func shortSHA(s string) string { | ||
| 626 | if len(s) > 8 { | ||
| 627 | return s[:8] | ||
| 628 | } | ||
| 629 | return s | ||
| 630 | } | ||
| 631 | |||
| 632 | func humanSince(t int64) string { | ||
| 633 | if t == 0 { | ||
| 634 | return "" | ||
| 635 | } | ||
| 636 | d := time.Since(time.Unix(t, 0)) | ||
| 637 | switch { | ||
| 638 | case d < time.Minute: | ||
| 639 | return "just now" | ||
| 640 | case d < time.Hour: | ||
| 641 | return fmt.Sprintf("%dm ago", int(d.Minutes())) | ||
| 642 | case d < 24*time.Hour: | ||
| 643 | return fmt.Sprintf("%dh ago", int(d.Hours())) | ||
| 644 | default: | ||
| 645 | return fmt.Sprintf("%dd ago", int(d.Hours()/24)) | ||
| 646 | } | ||
| 647 | } | ||