189 lines
5.7 KiB
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)
|
|
}
|