From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree. --- admin.go | 720 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 720 insertions(+) create mode 100644 admin.go (limited to 'admin.go') diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..cb03131 --- /dev/null +++ b/admin.go @@ -0,0 +1,720 @@ +package main + +import ( + "crypto/hmac" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" +) + +// Administration is web only, so the first admin cannot come from a shell. +func (f *Forge) setupOpen() bool { + return f.setupToken.Load() != nil && len(f.store.Users()) == 0 +} + +func (f *Forge) setupTokenOK(given string) bool { + want := f.setupToken.Load() + return want != nil && hmac.Equal([]byte(given), []byte(*want)) +} + +func (f *Forge) handleSetup(w http.ResponseWriter, r *http.Request) { + if !f.setupOpen() { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + f.render(w, "setup.html", http.StatusOK, f.page(r)) +} + +func (f *Forge) handleSetupPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + if !f.setupOpen() { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + + token := r.PostFormValue("token") + name := accountName(r.PostFormValue("name")) + email := strings.TrimSpace(r.PostFormValue("email")) + password, again := r.PostFormValue("password"), r.PostFormValue("again") + + d := f.page(r) + switch { + case !f.setupTokenOK(token): + d.Error = "wrong setup token, it was printed to the log at startup" + case !localNameRE.MatchString(name): + d.Error = "the account name must be lowercase letters, digits, dot, dash, or underscore" + case !validEmail(email): + d.Error = "that does not look like an email address" + case password != again: + d.Error = "the two passwords are not the same" + case len(password) < minPassword: + d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "setup.html", http.StatusBadRequest, d) + return + } + + admin := User{Origin: originLocal, Admin: true, Email: email, Notify: true, Password: hashPassword(password)} + err := f.store.UpdateUsers("users: create the first admin, "+name, Author{Name: name, Email: email}, + func(m map[string]User) error { + if len(m) > 0 { + return userError("an account already exists") + } + m[name] = admin + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + f.setupToken.Store(nil) + if email != "" { + f.mailLink(email, "confirm this address", + "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, admin)+"\n") + } + f.setSession(w, name, admin.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// requireAdmin gates the global admin pages; repository grants do not reach +// them. +func (f *Forge) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + name, u, ok := f.user(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return false + } + if !u.Admin { + f.fail(w, r, http.StatusForbidden, "this page is for global admins") + return false + } + if u.TOTP == nil { + log.Printf("admin: %s has the admin flag and no authenticator yet", name) + http.Redirect(w, r, "/settings/totp", http.StatusSeeOther) + return false + } + return true +} + +// adminPage is the one admin page. +type adminPage struct { + New Repo // the defaults the create form starts from + Repos []RepoEntry + Users map[string]User + Site *Site + Link string // a set-password link just minted, for the admin to convey + For string +} + +func (f *Forge) adminData() adminPage { + return adminPage{ + New: orphanRepo(), // private, both features off: the safe defaults + Repos: f.store.Repos(), Users: f.store.Users(), Site: f.site.Load(), + } +} + +// formImage reads and checks one upload, writing nothing. A nil body is no +// upload. +func formImage(r *http.Request, field string) ([]byte, error) { + // Not multipart, or no file in it: no upload. + file, _, err := r.FormFile(field) + if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + + body, err := io.ReadAll(io.LimitReader(file, maxImage+1)) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, nil + } + if err := checkImage(body); err != nil { + return nil, err + } + return body, nil +} + +// The name, subtitle, logo, and favicon are the only parts of forge.json a +// form may touch: the rest decides how the process starts. An upload goes in +// the config directory, so it is in the backup and the history. +func (f *Forge) handleSiteSettings(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + name := strings.TrimSpace(r.PostFormValue("site_name")) + desc := strings.TrimSpace(r.PostFormValue("site_desc")) + site := f.site.Load() + logo, icon := site.Logo.Name, site.Icon.Name + + // Refused before anything is written, so a refused save changes nothing. + if !validSiteName(name) { + f.adminError(w, r, errSiteName) + return + } + logoBody, err := formImage(r, "logo_file") + if err != nil { + f.adminError(w, r, err) + return + } + iconBody, err := formImage(r, "icon_file") + if err != nil { + f.adminError(w, r, err) + return + } + + // What this request wrote over, and what was there, so a save that fails + // afterwards can put it back. + var written []swap + if r.PostFormValue("logo") == "cgit" { + logo = "" // back to the one the image ships + } else if logoBody != nil { + if err := writeFile(f.store.File("logo"), logoBody, 0600); err != nil { + f.adminError(w, r, err) + return + } + written = append(written, swap{"logo", site.Logo}) + logo = "logo" + } + if r.PostFormValue("icon") == "cgit" { + icon = "" + } else if iconBody != nil { + if err := writeFile(f.store.File("icon"), iconBody, 0600); err != nil { + f.unwrite(written) + f.adminError(w, r, err) + return + } + written = append(written, swap{"icon", site.Icon}) + icon = "icon" + } + + // Read back before the config names it. + next, err := loadSite(f.store.configDir, name, desc, logo, icon) + if err == nil { + err = f.store.WriteConfig(f.actor(r), func(cfg *Config) { + cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon = name, desc, logo, icon + }) + } + if err != nil { + f.unwrite(written) + f.adminError(w, r, err) + return + } + f.site.Store(next) + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// swap is one image file a save wrote over, and what was there. +type swap struct { + file string + old Image +} + +// unwrite puts back what a failed save wrote over: the next start reads the +// file the config names. +func (f *Forge) unwrite(written []swap) { + for _, w := range written { + path := f.store.File(w.file) + err := os.Remove(path) + if w.old.Name == w.file { + err = writeFile(path, w.old.Body, 0600) + } + if err != nil { + log.Printf("admin: %s holds a refused image: %v", path, err) + } + } +} + +func (f *Forge) handleAdmin(w http.ResponseWriter, r *http.Request) { + if !f.requireAdmin(w, r) { + return + } + d := f.page(r) + d.Data = f.adminData() + f.render(w, "admin.html", http.StatusOK, d) +} + +// confirmCode asks the acting account for a code: the session says who they +// are, this says they are at the keyboard. Only a wrong code is counted, on +// the budget the login form spends. +func (f *Forge) confirmCode(w http.ResponseWriter, r *http.Request) bool { + name, u, ok := f.user(r) + if !ok || u.TOTP == nil { + f.fail(w, r, http.StatusForbidden, + "this asks for an authenticator code, and this account holds no authenticator. enrol one under settings") + return false + } + if f.totpLimit.over(accountKey(name), totpPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later") + return false + } + if !f.codeOK(name, u, r.PostFormValue("code")) { + f.totpLimit.charge(accountKey(name)) + f.fail(w, r, http.StatusForbidden, + "that authenticator code is not right, or was used already. wait for the next one. nothing was changed") + return false + } + return true +} + +// adminError draws /admin again with a message on it: there is nowhere to keep +// one across a redirect. +func (f *Forge) adminError(w http.ResponseWriter, r *http.Request, err error) { + var mine userError + if !errors.As(err, &mine) { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + d := f.page(r) + d.Data = f.adminData() + d.Error = mine.Error() + f.render(w, "admin.html", http.StatusBadRequest, d) +} + +func (f *Forge) handleRepoCreate(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + section := strings.TrimSpace(r.PostFormValue("section")) + name := strings.TrimSpace(r.PostFormValue("name")) + path := name + if section != "" { + path = section + "/" + name + } + repo := Repo{ + Desc: strings.TrimSpace(r.PostFormValue("desc")), + Visibility: r.PostFormValue("visibility"), + Issues: r.PostFormValue("issues"), + Wiki: r.PostFormValue("wiki"), + Labels: []string{}, + Grants: map[string]string{}, + } + if err := f.store.CreateRepo(path, repo, f.actor(r)); err != nil { + f.adminError(w, r, err) + return + } + who := f.viewer(r).Name + f.mailAdmins(who, "repository "+path+" created", + who+" created "+path+", "+repo.Visibility+".\n\n"+f.url("/"+path+"/")+"\n") + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// handleRepoAdopt records the safe settings a directory with no entry in +// repos.json is already being served with. +func (f *Forge) handleRepoAdopt(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + path := r.PostFormValue("path") + if _, ok := f.store.Repo(path); !ok { + f.fail(w, r, http.StatusNotFound, "no such repository") + return + } + err := f.store.UpdateRepos("repos: adopt "+path, f.actor(r), func(m map[string]Repo) error { + if _, ok := m[path]; ok { + return userError(path + " already has an entry") + } + m[path] = orphanRepo() + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// An admin never handles a password: a one time link is shown and, with mail, +// sent. It outlives this session, so it costs the admin's own code. +func (f *Forge) handleUserCreate(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + email := strings.TrimSpace(r.PostFormValue("email")) + + switch { + case !localNameRE.MatchString(name): + f.adminError(w, r, userError("the account name must be lowercase letters, digits, dot, dash, or underscore")) + return + case !validEmail(email): + f.adminError(w, r, userError("that does not look like an email address")) + return + } + + // No admin flag here: handing that out is a separate act. + acct := User{Origin: originLocal, Email: email, Notify: true} + err := f.store.UpdateUsers("users: create "+name, f.actor(r), func(m map[string]User) error { + if _, ok := m[name]; ok { + return userError(name + " already exists") + } + m[name] = acct + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + + link := f.passwordLink(name, acct, setPasswordLife) + if acct.Email != "" { + f.mailLink(acct.Email, "set a password for "+name, + "An admin made you an account named "+name+" on "+f.siteName()+ + ". Follow this link within two days to set a password:\n\n"+link+"\n") + } + d := f.page(r) + data := f.adminData() + data.Link, data.For = link, name + d.Data = data + f.render(w, "admin.html", http.StatusOK, d) +} + +func (f *Forge) handleUserAdmin(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + admin := r.PostFormValue("admin") != "" + verb := "clear" + if admin { + verb = "set" + } + + err := f.store.UpdateUsers(fmt.Sprintf("users: %s the admin flag on %s", verb, name), f.actor(r), + func(m map[string]User) error { + acct, ok := m[name] + if !ok { + return userError(name + " does not exist") + } + acct.Admin = admin + m[name] = acct + return lastAdminCheck(m) + }) + if err != nil { + f.adminError(w, r, err) + return + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// handleUserTOTP clears another account's second factor, the way back in when +// both authenticator and recovery codes are lost. Never an account's own. +func (f *Forge) handleUserTOTP(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + if acting, _, _ := f.user(r); acting == name { + f.fail(w, r, http.StatusForbidden, "clear your own authenticator from your settings, where it asks for your password") + return + } + if !f.confirmCode(w, r) { + return + } + err := f.store.UpdateUsers("users: clear the authenticator for "+name, f.actor(r), + func(m map[string]User) error { + acct, ok := m[name] + if !ok { + return userError(name + " does not exist") + } + if acct.TOTP == nil { + return userError(name + " has no authenticator") + } + acct.TOTP = nil + acct.Epoch++ // and every session for that account with it + m[name] = acct + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + if u, ok := f.store.User(name); ok { + f.mailAccount(u, "your authenticator was cleared", + "An admin cleared the authenticator for "+name+" on "+f.siteName()+ + ", and signed out every session for it. Enrol a new one when you next sign in.\n") + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// Every grant pointing at the account goes with it. +func (f *Forge) handleUserDelete(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + err := f.store.UpdateUsers("users: delete "+name, f.actor(r), func(m map[string]User) error { + if _, ok := m[name]; !ok { + return userError(name + " does not exist") + } + delete(m, name) + return lastAdminCheck(m) + }) + if err != nil { + f.adminError(w, r, err) + return + } + + err = f.store.UpdateRepos("repos: drop grants for "+name, f.actor(r), func(m map[string]Repo) error { + for path, repo := range m { + if _, ok := repo.Grants[name]; !ok { + continue + } + grants := map[string]string{} + for who, level := range repo.Grants { + if who != name { + grants[who] = level + } + } + repo.Grants = grants + m[path] = repo + } + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// lastAdminCheck refuses the change that would leave the forge with no way in. +func lastAdminCheck(m map[string]User) error { + for _, u := range m { + if u.Admin { + return nil + } + } + return userError("that would leave no global admin, and there is no way to make one from a shell") +} + +// repoSettings is the per repository page, for the admin grant. +func (f *Forge) repoSettings(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + v := f.viewer(r) + if !v.CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + _, u, _ := f.user(r) // administering a repository means being signed in + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "settings", repo) + d.Data = repoSettingsPage{ + Path: path, Repo: repo, Labels: strings.Join(repo.Labels, "\n"), + Delete: v.Admin, Enrolled: u.TOTP != nil, + } + f.render(w, "repo-settings.html", http.StatusOK, d) +} + +type repoSettingsPage struct { + Path string + Repo Repo + Labels string // one per line, for the textarea + Delete bool // deletion is the global admin flag's alone + // Enrolled says whether the grant forms are drawn: they ask for a code. + Enrolled bool +} + +func (f *Forge) repoSettingsSave(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + v := f.viewer(r) + if !v.CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + next := repo + next.Desc = strings.TrimSpace(r.PostFormValue("desc")) + next.Visibility = r.PostFormValue("visibility") + next.Issues = r.PostFormValue("issues") + next.Wiki = r.PostFormValue("wiki") + next.Labels = splitLabels(r.PostFormValue("labels")) + + if errs := next.validate(path); len(errs) > 0 { + _, u, _ := f.user(r) + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "settings", repo) + d.Data = repoSettingsPage{ + Path: path, Repo: next, Labels: r.PostFormValue("labels"), + Delete: v.Admin, Enrolled: u.TOTP != nil, + } + d.Error = errors.Join(errs...).Error() + f.render(w, "repo-settings.html", http.StatusBadRequest, d) + return + } + // These three hand out access that outlives the session, the way a grant + // does; a description or a label does not. + if next.Visibility != repo.Visibility || next.Issues != repo.Issues || next.Wiki != repo.Wiki { + if !f.confirmCode(w, r) { + return + } + } + // First, so a knob is never on with no repository behind it. + if err := f.store.EnsureSiblings(path, next); err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + err := f.store.UpdateRepos("repos: update settings for "+path, f.actor(r), func(m map[string]Repo) error { + // Onto the record as it is now: a grant that landed meanwhile has to + // survive. + entry, ok := m[path] + if !ok { + entry = orphanRepo() + } + entry.Desc, entry.Visibility = next.Desc, next.Visibility + entry.Issues, entry.Wiki, entry.Labels = next.Issues, next.Wiki, next.Labels + m[path] = entry + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + if next.Visibility != repo.Visibility { + f.mailAdmins(v.Name, "repository "+path+" is now "+next.Visibility, + v.Name+" changed "+path+" from "+repo.Visibility+" to "+next.Visibility+".\n\n"+f.url("/"+path+"/")+"\n") + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// splitLabels reads the textarea, one label per line, once each. +func splitLabels(s string) []string { + seen := map[string]bool{} + out := []string{} + for _, line := range strings.Split(s, "\n") { + label := strings.TrimSpace(line) + if label == "" || seen[label] { + continue + } + seen[label] = true + out = append(out, label) + } + sort.Strings(out) + return out +} + +// repoGrant adds, changes, or revokes one grant. An empty level revokes. +func (f *Forge) repoGrant(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + if !f.viewer(r).CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("account")) + level := r.PostFormValue("level") + + if _, ok := f.store.User(name); !ok && level != "" { + f.fail(w, r, http.StatusBadRequest, "no such account: "+name) + return + } + subject := fmt.Sprintf("repos: grant %s %s on %s", name, level, path) + if level == "" { + subject = fmt.Sprintf("repos: revoke %s on %s", name, path) + } + err := f.store.UpdateRepos(subject, f.actor(r), func(m map[string]Repo) error { + // A grant on an unadopted directory adopts it as served. + entry, ok := m[path] + if !ok { + entry = orphanRepo() + } + grants := map[string]string{} + for who, l := range entry.Grants { + if who != name { + grants[who] = l + } + } + if level != "" { + grants[name] = level + } + entry.Grants = grants + if errs := entry.validate(path); len(errs) > 0 { + return userError(errors.Join(errs...).Error()) + } + m[path] = entry + return nil + }) + if err != nil { + f.problem(w, r, http.StatusBadRequest, err) + return + } + if u, ok := f.store.User(name); ok { + what := "you now have " + level + " on " + path + if level == "" { + what = "your access to " + path + " has been revoked" + } + f.mailAccount(u, "access to "+path+" changed", what+".\n\n"+f.url("/"+path+"/")+"\n") + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// repoDelete removes the repository and both siblings, confirmed by typing the +// path: there is no undo. +func (f *Forge) repoDelete(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + if !f.viewer(r).Admin { + f.fail(w, r, http.StatusForbidden, "deleting a repository is for global admins") + return + } + if r.PostFormValue("confirm") != path { + f.fail(w, r, http.StatusBadRequest, "type the repository path exactly to confirm") + return + } + if !f.confirmCode(w, r) { + return + } + if err := f.store.DeleteRepo(path, f.actor(r)); err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + // A token is scoped to a path, and a path can be created again. + err := f.store.UpdateUsers("users: drop push tokens for "+path, f.actor(r), func(m map[string]User) error { + for name, u := range m { + left := []Token{} + for _, t := range u.Tokens { + if t.Repo != path { + left = append(left, t) + } + } + if len(left) != len(u.Tokens) { + u.Tokens = left + m[name] = u + } + } + return nil + }) + if err != nil { + log.Printf("repos: tokens for the deleted %s: %v", path, err) + } + who := f.viewer(r).Name + f.mailAdmins(who, "repository "+path+" deleted", + who+" deleted "+path+", with its issues and its wiki.\n") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// validEmail is shallow: an address is proved by a link arriving at it. +func validEmail(email string) bool { + if email == "" { + return true + } + local, domain, ok := strings.Cut(email, "@") + return ok && local != "" && domain != "" && !strings.ContainsAny(email, " \t\r\n<>,;") +} -- cgit v1.2.3