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<>,;") }