package main import ( "errors" "net/http" "net/url" "os" "path/filepath" "slices" "strconv" "strings" "time" ) const adminSchema = ` create table if not exists settings ( key text primary key, value text not null ); create table if not exists redirects ( owner text not null, name text not null, repo integer not null references repos(id) on delete cascade, primary key(owner, name) ); ` var registrationModes = []string{"open", "invite", "closed"} type settingEntry struct { at time.Time value string ok bool } func (s *Store) Setting(key, fallback string) string { s.settingsMu.Lock() e, hit := s.settings[key] s.settingsMu.Unlock() if !hit || time.Since(e.at) > 5*time.Second { e = settingEntry{at: time.Now()} e.ok = s.db.QueryRow(`select value from settings where key = ?`, key).Scan(&e.value) == nil s.settingsMu.Lock() s.settings[key] = e s.settingsMu.Unlock() } if !e.ok { return fallback } return e.value } func (s *Store) SetSetting(key, value string) error { _, err := s.db.Exec(`insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value`, key, value) s.settingsMu.Lock() delete(s.settings, key) s.settingsMu.Unlock() return err } func (s *Store) Registration() string { return s.Setting("registration", "open") } func validRegistration(mode string) bool { for _, m := range registrationModes { if m == mode { return true } } return false } func (s *Store) IsAdmin(id int64) bool { var admin bool s.db.QueryRow(`select admin from users where id = ? and banned = 0`, id).Scan(&admin) return admin } var themes = []string{"", "light", "dark"} func (s *Store) Viewer(id int64) (admin bool, theme string, unread int) { s.db.QueryRow(`select admin and not banned, theme, (select count(*) from notifications where user = ?1 and unread = 1) from users where id = ?1`, id).Scan(&admin, &theme, &unread) return } func (s *Store) SetTheme(id int64, theme string) error { if !slices.Contains(themes, theme) { return errors.New("Unknown theme") } _, err := s.db.Exec(`update users set theme = ? where id = ?`, theme, id) return err } func (s *Store) SetAdmin(name string, admin bool) error { res, err := s.db.Exec(`update users set admin = ? where name = ? and org = 0`, admin, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } func (s *Store) AddRedirect(owner, name string, repo int64) { s.db.Exec(`insert into redirects(owner, name, repo) values (?, ?, ?) on conflict(owner, name) do update set repo = excluded.repo`, owner, name, repo) } func (s *Store) Redirect(owner, name string) (Repo, bool) { var id int64 if err := s.db.QueryRow(`select repo from redirects where owner = ? and name = ?`, owner, name).Scan(&id); err != nil { return Repo{}, false } repo, err := s.RepoByID(id) return repo, err == nil } func (s *Store) Invites() []string { rows, err := s.db.Query(`select code from invites where used = 0 order by rowid desc limit 50`) if err != nil { return nil } defer rows.Close() var codes []string for rows.Next() { var c string if rows.Scan(&c) == nil { codes = append(codes, c) } } return codes } type account struct { User Admin bool Banned bool Org bool Repos int } func (s *Store) Accounts() []account { rows, err := s.db.Query(`select u.id, u.name, u.created, u.admin, u.banned, u.org, (select count(*) from repos r where r.owner = u.id) from users u order by u.created desc limit 1000`) if err != nil { return nil } defer rows.Close() var out []account for rows.Next() { var a account var created int64 if rows.Scan(&a.ID, &a.Name, &created, &a.Admin, &a.Banned, &a.Org, &a.Repos) == nil { a.Created = time.Unix(created, 0) out = append(out, a) } } return out } var adminSections = []struct{ Key, Title string }{ {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, } type siteStats struct { Users, Orgs, Admins, Banned, Repos, Private, Unlisted int } func (s *Store) Stats() siteStats { var st siteStats s.db.QueryRow(`select coalesce(sum(org = 0 and banned = 0), 0), coalesce(sum(org = 1), 0), coalesce(sum(admin = 1), 0), coalesce(sum(banned = 1), 0) from users`).Scan(&st.Users, &st.Orgs, &st.Admins, &st.Banned) s.db.QueryRow(`select count(*), coalesce(sum(private = 1), 0), coalesce(sum(private = 2), 0) from repos`).Scan(&st.Repos, &st.Private, &st.Unlisted) return st } func (s *Server) admin(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } if !s.store.IsAdmin(u.ID) { s.fail(w, r, 404, "Not found") return } if r.Method == "POST" { s.adminAction(w, r, u) return } section := r.URL.Query().Get("s") known := false for _, sec := range adminSections { known = known || sec.Key == section } if !known { section = "overview" } data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != ""} switch section { case "overview": accounts := s.store.Accounts() data["Stats"] = s.store.Stats() data["Recent"] = accounts[:min(5, len(accounts))] data["Registration"] = s.store.Registration() data["Mail"] = s.mailer != nil data["Used"] = usage(filepath.Join(s.root, "repos")) data["Quota"] = readQuota(s.root) case "people": q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) var shown []account for _, a := range s.store.Accounts() { if q == "" || strings.Contains(a.Name, q) { shown = append(shown, a) } } data["Accounts"], data["Query"] = shown, q case "signup": data["Registration"] = s.store.Registration() data["Invites"] = s.store.Invites() data["Mail"] = s.mailer != nil case "projects": featured, _ := s.store.FeaturedRepos() ranks := map[int64]int{} for i, f := range featured { ranks[f.ID] = i + 1 } public, _ := s.store.PublicRepos("", "") data["Ranks"], data["Public"] = ranks, public case "storage": quota := readQuota(s.root) type owner struct { Name string Used int64 Pct int } var owners []owner dirs, _ := os.ReadDir(filepath.Join(s.root, "repos")) for _, d := range dirs { if !d.IsDir() { continue } used := usage(filepath.Join(s.root, "repos", d.Name())) pct := 0 if quota > 0 { pct = int(min(100, used*100/quota)) } owners = append(owners, owner{d.Name(), used, pct}) } slices.SortFunc(owners, func(a, b owner) int { return int(b.Used - a.Used) }) if quota == 0 && len(owners) > 0 && owners[0].Used > 0 { for i := range owners { owners[i].Pct = max(1, int(owners[i].Used*100/owners[0].Used)) } } data["Owners"], data["Quota"], data["QuotaText"] = owners, quota, compactSize(quota) } s.render(w, r, "admin", page{Title: "Admin", User: u, Error: r.URL.Query().Get("error"), Data: data}) } func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) { section := r.FormValue("s") back := "/admin?s=" + url.QueryEscape(section) fail := func(msg string) { http.Redirect(w, r, back+"&error="+url.QueryEscape(msg), http.StatusSeeOther) } name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~") switch r.FormValue("action") { case "signup": mode := r.FormValue("mode") if !validRegistration(mode) { fail("Unknown registration mode") return } s.store.SetSetting("registration", mode) case "invite": if _, err := s.store.CreateInvite(); err != nil { fail(err.Error()) return } case "revoke": s.store.RevokeInvite(r.FormValue("code")) case "quota": n, err := parseSize(strings.TrimSpace(r.FormValue("quota"))) if err != nil { fail(err.Error()) return } if err := writeQuota(s.root, n); err != nil { fail(err.Error()) return } case "ban", "unban": if name == u.Name { fail("You cannot ban yourself") return } if err := s.store.SetBanned(name, r.FormValue("action") == "ban"); err != nil { fail("No such account") return } case "admin", "unadmin": if name == u.Name { fail("You cannot change your own admin rights") return } if err := s.store.SetAdmin(name, r.FormValue("action") == "admin"); err != nil { fail("No such user") return } case "feature": owner, repo, ok := strings.Cut(name, "/") rank, _ := strconv.Atoi(r.FormValue("rank")) if !ok || rank < 0 || s.store.SetFeatured(owner, repo, rank) != nil { fail("No such repository") return } default: fail("Unknown action") return } if q := r.FormValue("q"); q != "" { back += "&q=" + url.QueryEscape(q) } http.Redirect(w, r, back+"&saved=1", http.StatusSeeOther) } func (s *Store) RevokeInvite(code string) { s.db.Exec(`delete from invites where code = ? and used = 0`, code) } func (s *Server) newOrg(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } if r.Method == "GET" { s.render(w, r, "neworg", page{Title: "New organization", User: u}) return } name := strings.TrimSpace(r.FormValue("name")) if s.limiter.blocked("org:" + u.Name) { s.render(w, r, "neworg", page{Title: "New organization", User: u, Error: "Too many organizations created recently, try again later", Data: map[string]any{"Name": name}}) return } org, err := s.store.CreateOrg(name) if err == nil { err = s.store.SetMember(org.ID, u.ID, true) } if err != nil { w.WriteHeader(400) s.render(w, r, "neworg", page{Title: "New organization", User: u, Error: err.Error(), Data: map[string]any{"Name": name}}) return } s.limiter.fail("org:" + u.Name) http.Redirect(w, r, "/~"+org.Name+"/settings", http.StatusSeeOther) }