aboutsummaryrefslogtreecommitdiff
path: root/admin.go
diff options
context:
space:
mode:
Diffstat (limited to 'admin.go')
-rw-r--r--admin.go720
1 files changed, 720 insertions, 0 deletions
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<>,;")
+}