simpleadmin-web/internal/web/server.go

189 lines
5.7 KiB
Go

// 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)
}