// Package web serves the public site, the staff panel and their JSON APIs. package web import ( "context" "embed" "encoding/json" "errors" "io/fs" "log/slog" "net/http" "net/url" "strconv" "time" "git.zio.sh/cs2/simpleadmin-web/internal/live" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" "git.zio.sh/cs2/simpleadmin-web/internal/store" ) //go:embed assets var assets embed.FS type Config struct { BaseURL string // e.g. https://bans.example.com, no trailing slash SessionSecret []byte SiteFile string // ServerAddress is shown on the public page for "Join server". ServerAddress string // Limits used when SimpleAdmin's config file isn't available. MaxBanDuration int MaxMuteDuration int } type Server struct { cfg Config site Site st *store.Store rc *rcon.Client poll *live.Poller sacfg *saconfig.File // nil when no config path is set staff *staffCache http *http.Client secure bool } func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg *saconfig.File) (*Server, error) { site, err := loadSite(cfg.SiteFile) if err != nil { return nil, err } u, err := url.Parse(cfg.BaseURL) if err != nil || u.Host == "" { return nil, errors.New("base URL must be absolute, like https://bans.example.com") } return &Server{ cfg: cfg, site: site, st: st, rc: rc, poll: poll, sacfg: sacfg, staff: &staffCache{st: st, ttl: 15 * time.Second}, http: &http.Client{Timeout: 10 * time.Second}, secure: u.Scheme == "https", }, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() static, _ := fs.Sub(assets, "assets") files := http.FileServerFS(static) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, static, "public.html") }) mux.HandleFunc("GET /admin", s.adminPage(static)) mux.Handle("GET /static/", http.StripPrefix("/static/", files)) mux.HandleFunc("GET /auth/login", s.login) mux.HandleFunc("GET /auth/callback", s.callback) mux.HandleFunc("POST /auth/logout", s.logout) mux.HandleFunc("GET /api/me", s.me) mux.HandleFunc("GET /api/public/server", s.publicServer) mux.HandleFunc("GET /api/public/bans", s.publicBans) mux.HandleFunc("GET /api/public/comms", s.publicComms) mux.HandleFunc("GET /api/public/staff", s.publicStaff) mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) } admin("GET /api/admin/overview", s.adminOverview) admin("GET /api/admin/players", s.adminPlayers) admin("GET /api/admin/players/{steamid}", s.adminPlayer) admin("GET /api/admin/bans", s.adminBans) admin("GET /api/admin/comms", s.adminComms) admin("GET /api/admin/staff", s.adminStaff) admin("GET /api/admin/settings", s.adminSettings) admin("POST /api/admin/penalties", s.addPenalty) admin("POST /api/admin/penalties/lift", s.liftPenalty) admin("POST /api/admin/kick", s.kick) admin("POST /api/admin/say", s.say) admin("POST /api/admin/map", s.changeMap) admin("POST /api/admin/restart", s.restartRound) admin("POST /api/admin/reload-admins", s.reloadAdmins) admin("POST /api/admin/groups", s.createGroup) admin("PUT /api/admin/groups/{id}", s.updateGroup) admin("DELETE /api/admin/groups/{id}", s.deleteGroup) admin("POST /api/admin/staff", s.addStaff) admin("PUT /api/admin/staff/{steamid}", s.setStaffRank) admin("DELETE /api/admin/staff/{steamid}", s.removeStaff) admin("POST /api/admin/settings/cvars", s.saveCvars) admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin) return s.headers(mux) } func (s *Server) headers(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; frame-ancestors 'none'") next.ServeHTTP(w, r) }) } // ---------------------------------------------------------------- JSON helpers type apiError struct { Error string `json:"error"` } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func fail(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, apiError{Error: msg}) } // internal logs the real error and tells the browser something generic. func internal(w http.ResponseWriter, r *http.Request, err error) { slog.Error("request failed", "path", r.URL.Path, "err", err) fail(w, http.StatusInternalServerError, "Something went wrong on the server. The error has been logged.") } func readJSON(r *http.Request, v any) error { dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return errors.New("the request wasn't valid JSON") } return nil } func page(r *http.Request) (limit, offset, num int) { num, _ = strconv.Atoi(r.URL.Query().Get("page")) if num < 1 { num = 1 } return pageSize, (num - 1) * pageSize, num } const pageSize = 50 type list[T any] struct { Items []T `json:"items"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` } func newList[T any](items []T, total, num int) list[T] { if items == nil { items = []T{} } return list[T]{Items: items, Total: total, Page: num, PageSize: pageSize} } func (s *Server) staffIdx(ctx context.Context) (*staffIndex, error) { return s.staff.get(ctx) }