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.
Move writes to SSH, drop web authentication, keep identity in git
a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc
Move writes to SSH, drop web authentication, keep identity in git The authenticated-HTTP design is gone rather than extended. Writes now require an SSH key, the web is read-only, and the user roster lives in git. What replaced what: - internal/sshserver is the only write transport. An embedded SSH server (golang.org/x/crypto/ssh) authenticates by public key and hands the principal to pre-receive through the environment, so the hook's policy code is unchanged. The exec surface is whitelisted to git-upload-pack and git-receive-pack, the repository argument is resolved through the store rather than concatenated, and everything else -- including `sh -c` -- is refused and asserted refused in a test. - internal/identity stores users on refs/heads/main in data/system.git. The directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS commits, reads come from an in-memory snapshot so the SSH auth callback is a map lookup. Nothing stored is a secret, so a clone of system.git is a complete backup and git log on it is the audit trail. - internal/transport is now anonymous read-only: upload-pack behind a visibility check, 404 for private repositories, and no push path at all. - internal/web has no login, no sessions and no mutations. Deleted outright: internal/auth, the Basic-auth path, login/logout, web settings/rename/delete, chunked body spooling, the spool janitor, the push semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize per repo, which enforces it on every transport without buffering. Three things worth recording: - The CAS helper read the tree outside the retry loop and compared against a freshly-read old value, so a concurrent writer's commit could be silently discarded while the CAS "succeeded". A test with two stores adding eight keys at once found it losing half of them; read, mutate and compare now all happen inside the loop. - The roster moved from refs/vole/users to refs/heads/main because `git clone` only mirrors refs/heads/*: the documented backup command produced an instance with zero users. Storing on a branch also means git itself refuses to point the ref at a non-commit. - Exec refusals are answered on the channel rather than by rejecting the request. Rejecting made the client print "exec request failed on channel 0", so the reason was unreadable. Verified by hand as well as by test: init -> user -> add-key -> repo -> serve, push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on both anonymous surfaces while still cloning over SSH, a refused `sh -c` that prints why and executes nothing, and a plain clone of system.git yielding the whole roster with no side database present.
internal/web/web.go +62−352
| @@ -1,6 +1,9 @@ | |||
| 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. | ||
| 1 | // Package web is Vole's read-only HTML surface. | ||
| 2 | // | ||
| 3 | // There is no login: everything the web serves already has to be readable by an | ||
| 4 | // anonymous principal, which is the same rule the HTTP git transport enforces. | ||
| 5 | // Repositories that are not public answer 404 rather than 403, so the web never | ||
| 6 | // confirms that a private repository exists. | ||
| 4 | 7 | package web | |
| 5 | 8 | ||
| 6 | 9 | import ( | |
| @@ -13,88 +16,48 @@ import ( | |||
| 13 | 16 | "unicode/utf8" | |
| 14 | 17 | ||
| 15 | 18 | "github.com/rodent-software/vole/internal/acl" | |
| 16 | "github.com/rodent-software/vole/internal/auth" | ||
| 17 | 19 | "github.com/rodent-software/vole/internal/config" | |
| 18 | 20 | "github.com/rodent-software/vole/internal/gitx" | |
| 19 | 21 | "github.com/rodent-software/vole/internal/index" | |
| 20 | 22 | "github.com/rodent-software/vole/internal/repo" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | const SessionCookie = "vole_session" | ||
| 24 | |||
| 25 | 25 | type Web struct { | |
| 26 | 26 | Repos *repo.Store | |
| 27 | Auth *auth.Store | ||
| 28 | 27 | Index *index.Store | |
| 29 | 28 | Cfg *config.Config | |
| 30 | 29 | Tmpl *template.Template | |
| 31 | 30 | Logger func(format string, args ...any) | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | func New(repos *repo.Store, a *auth.Store, ix *index.Store, cfg *config.Config) (*Web, error) { | ||
| 33 | func New(repos *repo.Store, ix *index.Store, cfg *config.Config) (*Web, error) { | ||
| 35 | 34 | t, err := template.New("vole").Funcs(template.FuncMap{ | |
| 36 | 35 | "since": humanSince, | |
| 37 | 36 | "short": shortSHA, | |
| 38 | 37 | "esc": url.PathEscape, | |
| 39 | "join": func(v []string) string { return strings.Join(v, "\n") }, | ||
| 40 | "list": func(v ...string) []string { return v }, | ||
| 41 | 38 | }).Parse(templates) | |
| 42 | 39 | if err != nil { | |
| 43 | 40 | return nil, err | |
| 44 | 41 | } | |
| 45 | w := &Web{Repos: repos, Auth: a, Index: ix, Cfg: cfg, Tmpl: t} | ||
| 42 | w := &Web{Repos: repos, Index: ix, Cfg: cfg, Tmpl: t} | ||
| 46 | 43 | w.Logger = func(string, ...any) {} | |
| 47 | 44 | return w, nil | |
| 48 | 45 | } | |
| 49 | 46 | ||
| 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 | ||
| 47 | // Page is embedded by every view. Exported so html/template can promote its | ||
| 48 | // fields; an unexported embedded type is not reachable from a template. | ||
| 49 | type Page struct { | ||
| 50 | Title string | ||
| 51 | Error string | ||
| 77 | 52 | } | |
| 78 | 53 | ||
| 79 | func (w *Web) roleFor(m *repo.Meta, u *auth.User) acl.Role { | ||
| 54 | // public reports whether the repository may be served to an anonymous reader. | ||
| 55 | func (w *Web) public(m *repo.Meta) bool { | ||
| 80 | 56 | a, err := m.ACL() | |
| 81 | 57 | if err != nil { | |
| 82 | return acl.None | ||
| 83 | } | ||
| 84 | if u == nil { | ||
| 85 | return a.RoleFor("") | ||
| 58 | return false | ||
| 86 | 59 | } | |
| 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 | ||
| 60 | return a.Can("", acl.Read) | ||
| 98 | 61 | } | |
| 99 | 62 | ||
| 100 | 63 | func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) { | |
| @@ -107,101 +70,45 @@ func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) | |||
| 107 | 70 | } | |
| 108 | 71 | } | |
| 109 | 72 | ||
| 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 | } | ||
| 73 | func (w *Web) base(title string) Page { return Page{Title: title} } | ||
| 132 | 74 | ||
| 133 | 75 | // ---------- handlers ---------- | |
| 134 | 76 | ||
| 135 | 77 | 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() | ||
| 78 | entries, err := w.Index.List() | ||
| 141 | 79 | if err != nil { | |
| 142 | 80 | http.Error(rw, "index unavailable: "+err.Error(), 500) | |
| 143 | 81 | return | |
| 144 | 82 | } | |
| 145 | // Only show repos the user can read. | ||
| 146 | 83 | var visible []index.Entry | |
| 147 | for _, e := range repos { | ||
| 84 | for _, e := range entries { | ||
| 148 | 85 | m, err := w.Repos.Open(e.Name) | |
| 149 | 86 | if err != nil { | |
| 150 | 87 | continue | |
| 151 | 88 | } | |
| 152 | if w.roleFor(m, u).AtLeast(acl.Read) { | ||
| 89 | if w.public(m) { | ||
| 153 | 90 | visible = append(visible, e) | |
| 154 | 91 | } | |
| 155 | 92 | } | |
| 156 | 93 | w.render(rw, "index", struct { | |
| 157 | 94 | Page | |
| 158 | 95 | 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) | ||
| 96 | }{w.base("Repositories"), visible}, 0) | ||
| 182 | 97 | } | |
| 183 | 98 | ||
| 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 { | ||
| 99 | // open resolves a repository for an anonymous reader, answering 404 when it is | ||
| 100 | // missing or not public. | ||
| 101 | func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { | ||
| 102 | m, err := w.Repos.Open(r.PathValue("name")) | ||
| 103 | if err != nil || !w.public(m) { | ||
| 192 | 104 | 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 | ||
| 105 | return nil, false | ||
| 199 | 106 | } | |
| 200 | return m, u, role, true | ||
| 107 | return m, true | ||
| 201 | 108 | } | |
| 202 | 109 | ||
| 203 | 110 | func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |
| 204 | m, _, role, ok := w.repo(rw, r) | ||
| 111 | m, ok := w.open(rw, r) | ||
| 205 | 112 | if !ok { | |
| 206 | 113 | return | |
| 207 | 114 | } | |
| @@ -229,22 +136,20 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 229 | 136 | Name string | |
| 230 | 137 | Description string | |
| 231 | 138 | Ref string | |
| 232 | Role acl.Role | ||
| 233 | RepoAdmin bool | ||
| 234 | 139 | CloneURL string | |
| 140 | SSHURL string | ||
| 235 | 141 | Entries []gitx.Entry | |
| 236 | 142 | Commits []gitx.Commit | |
| 237 | 143 | Commit string | |
| 238 | 144 | Count int | |
| 239 | 145 | Branches []string | |
| 240 | 146 | }{ | |
| 241 | Page: w.base(r, m.Name), | ||
| 147 | Page: w.base(m.Name), | ||
| 242 | 148 | Name: m.Name, | |
| 243 | 149 | Description: m.Description, | |
| 244 | 150 | Ref: ref, | |
| 245 | Role: role, | ||
| 246 | RepoAdmin: role.AtLeast(acl.Admin), | ||
| 247 | 151 | CloneURL: cloneURL(r, m.Name), | |
| 152 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 248 | 153 | Entries: entries, | |
| 249 | 154 | Commits: commits, | |
| 250 | 155 | Commit: commit, | |
| @@ -254,7 +159,7 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 254 | 159 | } | |
| 255 | 160 | ||
| 256 | 161 | func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |
| 257 | m, _, _, ok := w.repo(rw, r) | ||
| 162 | m, ok := w.open(rw, r) | ||
| 258 | 163 | if !ok { | |
| 259 | 164 | return | |
| 260 | 165 | } | |
| @@ -275,7 +180,7 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |||
| 275 | 180 | } | |
| 276 | 181 | entries, err := g.LsTree(treeish) | |
| 277 | 182 | if err != nil { | |
| 278 | http.Error(rw, "not a directory: "+err.Error(), http.StatusBadRequest) | ||
| 183 | http.Error(rw, "not a directory", http.StatusBadRequest) | ||
| 279 | 184 | return | |
| 280 | 185 | } | |
| 281 | 186 | w.render(rw, "tree", struct { | |
| @@ -285,21 +190,17 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |||
| 285 | 190 | Path string | |
| 286 | 191 | Entries []gitx.Entry | |
| 287 | 192 | Crumbs []crumb | |
| 288 | }{w.base(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(ref, p)}, 0) | ||
| 193 | }{w.base(m.Name + " · " + ref), m.Name, ref, p, entries, crumbs(p)}, 0) | ||
| 289 | 194 | } | |
| 290 | 195 | ||
| 291 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { | ||
| 292 | w.blob(rw, r, false) | ||
| 293 | } | ||
| 196 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) } | ||
| 294 | 197 | ||
| 295 | func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { | ||
| 296 | w.blob(rw, r, true) | ||
| 297 | } | ||
| 198 | func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) } | ||
| 298 | 199 | ||
| 299 | 200 | const maxInlineBytes = 1 << 20 | |
| 300 | 201 | ||
| 301 | 202 | func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |
| 302 | m, _, _, ok := w.repo(rw, r) | ||
| 203 | m, ok := w.open(rw, r) | ||
| 303 | 204 | if !ok { | |
| 304 | 205 | return | |
| 305 | 206 | } | |
| @@ -341,11 +242,11 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |||
| 341 | 242 | Truncated bool | |
| 342 | 243 | Size int64 | |
| 343 | 244 | Crumbs []crumb | |
| 344 | }{w.base(r, m.Name+" · "+p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(ref, p)}, 0) | ||
| 245 | }{w.base(m.Name + " · " + p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(p)}, 0) | ||
| 345 | 246 | } | |
| 346 | 247 | ||
| 347 | 248 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |
| 348 | m, _, _, ok := w.repo(rw, r) | ||
| 249 | m, ok := w.open(rw, r) | ||
| 349 | 250 | if !ok { | |
| 350 | 251 | return | |
| 351 | 252 | } | |
| @@ -366,199 +267,7 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |||
| 366 | 267 | Name string | |
| 367 | 268 | Ref string | |
| 368 | 269 | 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) | ||
| 270 | }{w.base(m.Name + " · history"), m.Name, ref, commits}, 0) | ||
| 562 | 271 | } | |
| 563 | 272 | ||
| 564 | 273 | // ---------- helpers ---------- | |
| @@ -571,8 +280,22 @@ func cloneURL(r *http.Request, name string) string { | |||
| 571 | 280 | if p := r.Header.Get("X-Forwarded-Proto"); p != "" { | |
| 572 | 281 | scheme = p | |
| 573 | 282 | } | |
| 574 | host := r.Host | ||
| 575 | return fmt.Sprintf("%s://%s/git/%s.git", scheme, host, name) | ||
| 283 | return fmt.Sprintf("%s://%s/git/%s.git", scheme, r.Host, name) | ||
| 284 | } | ||
| 285 | |||
| 286 | func sshURL(addr, name string) string { | ||
| 287 | host, port, ok := strings.Cut(addr, ":") | ||
| 288 | if !ok { | ||
| 289 | host = addr | ||
| 290 | } | ||
| 291 | return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name) | ||
| 292 | } | ||
| 293 | |||
| 294 | func netJoin(host, port string) string { | ||
| 295 | if port == "" || port == "22" { | ||
| 296 | return host | ||
| 297 | } | ||
| 298 | return host + ":" + port | ||
| 576 | 299 | } | |
| 577 | 300 | ||
| 578 | 301 | func safePath(p string) bool { | |
| @@ -592,11 +315,10 @@ type crumb struct { | |||
| 592 | 315 | Path string | |
| 593 | 316 | } | |
| 594 | 317 | ||
| 595 | func crumbs(ref, p string) []crumb { | ||
| 596 | out := []crumb{{Name: strings.SplitN(ref, "/", 2)[0], Path: ""}} | ||
| 318 | func crumbs(p string) []crumb { | ||
| 319 | var out []crumb | ||
| 597 | 320 | cur := "" | |
| 598 | parts := strings.Split(p, "/") | ||
| 599 | for i, seg := range parts { | ||
| 321 | for _, seg := range strings.Split(p, "/") { | ||
| 600 | 322 | if seg == "" { | |
| 601 | 323 | continue | |
| 602 | 324 | } | |
| @@ -605,23 +327,11 @@ func crumbs(ref, p string) []crumb { | |||
| 605 | 327 | } else { | |
| 606 | 328 | cur += "/" + seg | |
| 607 | 329 | } | |
| 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 | } | ||
| 330 | out = append(out, crumb{Name: seg, Path: cur}) | ||
| 613 | 331 | } | |
| 614 | 332 | return out | |
| 615 | 333 | } | |
| 616 | 334 | ||
| 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 | 335 | func shortSHA(s string) string { | |
| 626 | 336 | if len(s) > 8 { | |
| 627 | 337 | return s[:8] | |