package web import ( "crypto/rand" "encoding/hex" "fmt" "net/http" "regexp" "strconv" "strings" "time" "git.veillax.com/Veillax/megalodon/internal/ark" "git.veillax.com/Veillax/megalodon/internal/config" "git.veillax.com/Veillax/megalodon/internal/manager" ) func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) { type macro struct { Name string `json:"name"` } var macros []macro for _, m := range s.cfg.Get().Macros { if p.Role.Allows(m.Role) { macros = append(macros, macro{m.Name}) } } writeJSON(w, map[string]any{"name": p.Name, "role": p.Role, "version": s.version, "macros": macros, "reference": ark.Reference}) } // ---- servers ---- func (s *Server) listServers(w http.ResponseWriter, r *http.Request, p principal) { writeJSON(w, s.m.Views()) } var nonID = regexp.MustCompile(`[^a-z0-9]+`) func (s *Server) addServer(w http.ResponseWriter, r *http.Request, p principal) { var in config.Server if !decode(w, r, &in) { return } if in.ID == "" { in.ID = strings.Trim(nonID.ReplaceAllString(strings.ToLower(in.Name), "-"), "-") } err := s.cfg.Update(func(c *config.Config) error { c.Servers = append(c.Servers, in) return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Reload() s.m.Audit(p.Name, in.ID, "added server "+in.Name) writeJSON(w, map[string]string{"id": in.ID}) } func (s *Server) editServer(w http.ResponseWriter, r *http.Request, p principal) { id := r.PathValue("id") var in config.Server if !decode(w, r, &in) { return } in.ID = id found := false err := s.cfg.Update(func(c *config.Config) error { for k := range c.Servers { if c.Servers[k].ID == id { if in.Password == "" { // blank means keep the current one in.Password = c.Servers[k].Password } c.Servers[k], found = in, true } } return nil }) switch { case err != nil: fail(w, http.StatusBadRequest, err.Error()) case !found: fail(w, http.StatusNotFound, "no such server") default: s.m.Reload() s.m.Audit(p.Name, id, "edited server settings") writeJSON(w, map[string]bool{"ok": true}) } } func (s *Server) deleteServer(w http.ResponseWriter, r *http.Request, p principal) { id := r.PathValue("id") err := s.cfg.Update(func(c *config.Config) error { var keep []config.Server for _, sv := range c.Servers { if sv.ID != id { keep = append(keep, sv) } } c.Servers = keep for k := range c.Jobs { // a job aimed only at this server would now hit every server var left []string for _, sid := range c.Jobs[k].Servers { if sid != id { left = append(left, sid) } } if len(c.Jobs[k].Servers) > 0 && len(left) == 0 { c.Jobs[k].Enabled = false } c.Jobs[k].Servers = left } return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Reload() s.m.Audit(p.Name, "", "removed server "+id) writeJSON(w, map[string]bool{"ok": true}) } func (s *Server) samples(w http.ResponseWriter, r *http.Request, p principal) { i, ok := s.m.Instance(r.PathValue("id")) if !ok { fail(w, http.StatusNotFound, "no such server") return } writeJSON(w, i.Samples()) } // ---- actions ---- func (s *Server) serverAction(w http.ResponseWriter, r *http.Request, p principal) { var a manager.Action if !decode(w, r, &a) { return } a.Servers = []string{r.PathValue("id")} s.runAction(w, p, a) } func (s *Server) clusterAction(w http.ResponseWriter, r *http.Request, p principal) { var a manager.Action if !decode(w, r, &a) { return } s.runAction(w, p, a) } func (s *Server) runAction(w http.ResponseWriter, p principal, a manager.Action) { if !p.Role.Allows(manager.ActionRole(a.Name)) { fail(w, http.StatusForbidden, "your role cannot do this") return } if a.Minutes < 0 || a.Minutes > 24*60*365 { fail(w, http.StatusBadRequest, "minutes out of range") return } results, err := s.m.Do(p.Name, a) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, results) } func (s *Server) runMacro(w http.ResponseWriter, r *http.Request, p principal) { var in struct{ Name string } if !decode(w, r, &in) { return } for _, m := range s.cfg.Get().Macros { if m.Name == in.Name { if !p.Role.Allows(m.Role) { fail(w, http.StatusForbidden, "your role cannot run this macro") return } res, err := s.m.RunMacro(p.Name, r.PathValue("id"), m) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, res) return } } fail(w, http.StatusNotFound, "no such macro") } func (s *Server) runJob(w http.ResponseWriter, r *http.Request, p principal) { for _, j := range s.cfg.Get().Jobs { if j.ID == r.PathValue("id") { writeJSON(w, s.m.RunJob(j, p.Name+" (run now)")) return } } fail(w, http.StatusNotFound, "no such job") } func (s *Server) testNotify(w http.ResponseWriter, r *http.Request, p principal) { var in struct{ Name string } if !decode(w, r, &in) { return } if err := s.m.TestNotify(in.Name); err != nil { fail(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, map[string]bool{"ok": true}) } // ---- events, players, bans ---- func (s *Server) events(w http.ResponseWriter, r *http.Request, p principal) { q := r.URL.Query() f := manager.Filter{Server: q.Get("server"), Query: q.Get("q")} if t := q.Get("type"); t != "" { f.Types = strings.Split(t, ",") } f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64) f.Limit, _ = strconv.Atoi(q.Get("limit")) // Console audit lines can hold commands with sensitive values; only moderators and up see them. events := s.m.Hub().Recent(f) if !p.Role.Allows(config.Moderator) { kept := events[:0] for _, e := range events { if e.Type != "audit" && e.Type != "error" { kept = append(kept, e) } } events = kept } writeJSON(w, events) } func (s *Server) stream(w http.ResponseWriter, r *http.Request, p principal) { fl, ok := w.(http.Flusher) if !ok { fail(w, http.StatusInternalServerError, "streaming not supported") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("X-Accel-Buffering", "no") ch, cancel := s.m.Hub().Subscribe() defer cancel() fmt.Fprint(w, ": connected\n\n") fl.Flush() ping := time.NewTicker(20 * time.Second) defer ping.Stop() for { select { case <-r.Context().Done(): return case <-ping.C: fmt.Fprint(w, ": ping\n\n") fl.Flush() case e := <-ch: if !p.Role.Allows(config.Moderator) && (e.Type == "audit" || e.Type == "error") { continue } b, _ := jsonBytes(e) fmt.Fprintf(w, "data: %s\n\n", b) fl.Flush() } } } func (s *Server) players(w http.ResponseWriter, r *http.Request, p principal) { recs := s.m.Players() if !p.Role.Allows(config.Moderator) { for k := range recs { recs[k].Notes = "" // moderator notes stay between moderators } } writeJSON(w, recs) } func (s *Server) annotate(w http.ResponseWriter, r *http.Request, p principal) { var in struct { Notes string `json:"notes"` Watch bool `json:"watch"` } if !decode(w, r, &in) { return } if err := s.m.Annotate(r.PathValue("id"), in.Notes, in.Watch); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, map[string]bool{"ok": true}) } func (s *Server) bans(w http.ResponseWriter, r *http.Request, p principal) { writeJSON(w, s.m.Bans()) } // ---- settings ---- type settings struct { Jobs []config.Job `json:"jobs"` Macros []config.Macro `json:"macros"` Notify []config.Notify `json:"notify"` Welcome config.Welcome `json:"welcome"` ChatFilter config.ChatFilter `json:"chat_filter"` } func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p principal) { c := s.cfg.Get() writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter}) } func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p principal) { var in settings if !decode(w, r, &in) { return } for k := range in.Jobs { if in.Jobs[k].ID == "" { b := make([]byte, 4) rand.Read(b) in.Jobs[k].ID = hex.EncodeToString(b) } } err := s.cfg.Update(func(c *config.Config) error { c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter = in.Jobs, in.Macros, in.Notify, in.Welcome, in.ChatFilter return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Audit(p.Name, "", "changed schedules, macros, notifications or filters") c := s.cfg.Get() writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter}) } // ---- users and tokens ---- func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p principal) { type user struct { Name string `json:"name"` Role config.Role `json:"role"` } out := []user{} for _, u := range s.cfg.Get().Users { out = append(out, user{u.Name, u.Role}) } writeJSON(w, out) } func (s *Server) saveUser(w http.ResponseWriter, r *http.Request, p principal) { var in struct { Name string `json:"name"` Role config.Role `json:"role"` Password string `json:"password"` } if !decode(w, r, &in) { return } in.Name = strings.TrimSpace(in.Name) err := s.cfg.Update(func(c *config.Config) error { for k := range c.Users { if c.Users[k].Name == in.Name { if c.Users[k].Role == config.Admin && in.Role != config.Admin && countAdmins(c) == 1 { return fmt.Errorf("there must be at least one admin") } c.Users[k].Role = in.Role if in.Password != "" { h, err := newHash(in.Password) if err != nil { return err } c.Users[k].Hash = h } return nil } } h, err := newHash(in.Password) if err != nil { return err } c.Users = append(c.Users, config.User{Name: in.Name, Role: in.Role, Hash: h}) return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Audit(p.Name, "", "saved user "+in.Name+" ("+string(in.Role)+")") writeJSON(w, map[string]bool{"ok": true}) } func newHash(password string) (string, error) { if len(password) < 8 { return "", fmt.Errorf("the password must be at least 8 characters") } return config.HashPassword(password) } func countAdmins(c *config.Config) int { n := 0 for _, u := range c.Users { if u.Role == config.Admin { n++ } } return n } func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p principal) { name := r.PathValue("name") err := s.cfg.Update(func(c *config.Config) error { var keep []config.User for _, u := range c.Users { if u.Name != name { keep = append(keep, u) } } c.Users = keep if countAdmins(c) == 0 { return fmt.Errorf("there must be at least one admin") } return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.mu.Lock() for k, sess := range s.sessions { if sess.user == name { delete(s.sessions, k) } } s.mu.Unlock() s.m.Audit(p.Name, "", "deleted user "+name) writeJSON(w, map[string]bool{"ok": true}) } func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) { type token struct { Name string `json:"name"` Role config.Role `json:"role"` } out := []token{} for _, t := range s.cfg.Get().Tokens { out = append(out, token{t.Name, t.Role}) } writeJSON(w, out) } func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) { var in struct { Name string `json:"name"` Role config.Role `json:"role"` } if !decode(w, r, &in) { return } token, hash, err := config.NewToken() if err == nil { err = s.cfg.Update(func(c *config.Config) error { for _, t := range c.Tokens { if t.Name == in.Name { return fmt.Errorf("a token named %q already exists", in.Name) } } c.Tokens = append(c.Tokens, config.Token{Name: in.Name, Role: in.Role, Hash: hash}) return nil }) } if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Audit(p.Name, "", "created API token "+in.Name+" ("+string(in.Role)+")") writeJSON(w, map[string]string{"token": token}) // the only time it is shown } func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) { name := r.PathValue("name") err := s.cfg.Update(func(c *config.Config) error { var keep []config.Token for _, t := range c.Tokens { if t.Name != name { keep = append(keep, t) } } c.Tokens = keep return nil }) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } s.m.Audit(p.Name, "", "deleted API token "+name) writeJSON(w, map[string]bool{"ok": true}) }