v0.3.0-prod

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 14:57:48 +01:00
1 parent 387933957e
commit 677a0e4d2c
20 files changed
+1239 -180

No files matched your search

+222 -18
View File
@@ -2,16 +2,18 @@ package web
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -25,6 +27,38 @@ import (
//go:embed templates/*.html assets/*
var embedded embed.FS
// assetHashes maps a plain asset filename ("app.css") to a short content
// hash, computed once at startup from the embedded file bytes. Templates
// reference assets through the "asset" func (assetURL below), which builds
// a hashed URL such as /assets/app.a1b2c3d4e5.css; the asset handler then
// serves that URL with a one-year immutable Cache-Control, since a content
// change always produces a new hash (and therefore a new URL) rather than
// invalidating a cached one.
var assetHashes = map[string]string{}
func init() {
for _, name := range []string{"app.css", "app.js"} {
data, err := embedded.ReadFile("assets/" + name)
if err != nil {
continue
}
sum := sha256.Sum256(data)
assetHashes[name] = hex.EncodeToString(sum[:])[:10]
}
}
// assetURL returns the cache-busted URL for a static asset, falling back to
// the plain /assets/<name> path if no hash was computed for it.
func assetURL(name string) string {
hash, ok := assetHashes[name]
if !ok {
return "/assets/" + name
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
return "/assets/" + base + "." + hash + ext
}
type Server struct {
cfg config.Config
ts *ts3.Query
@@ -44,17 +78,27 @@ type channelPage struct {
Error string
Success string
ChannelID int
VoiceURL string
VoiceURL template.URL
Name string
Topic string
MaxClients string
CodecQuality string
}
type statsPage struct {
Current int
VoiceURL string
Ranges []rrd.Range
Current int
VoiceURL template.URL
Ranges []rrd.Range
ServerName string
ServerVersion string
ServerPlatform string
Uptime string
MaxClients int
InfoAvailable bool
}
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
tpl, err := template.ParseFS(embedded, "templates/*.html")
tpl, err := template.New("").Funcs(template.FuncMap{"asset": assetURL}).ParseFS(embedded, "templates/*.html")
if err != nil {
return nil, err
}
@@ -63,10 +107,10 @@ func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
assets, _ := fs.Sub(embedded, "assets")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assets))))
mux.HandleFunc("GET /assets/{file}", s.asset)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
mux.HandleFunc("GET /stats", s.stats)
mux.HandleFunc("GET /stats/live", s.statsLive)
mux.HandleFunc("GET /graph/{file}", s.graph)
mux.HandleFunc("GET /channel", s.channel)
mux.HandleFunc("GET /embed/channel", s.channel)
@@ -112,8 +156,98 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
defer cancel2()
current, _ = s.rrd.Last(ctx2)
}
p := statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges}
ctx3, cancel3 := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel3()
if info, err := s.ts.ServerInfo(ctx3); err == nil {
p.InfoAvailable = true
p.ServerName = info.Name
p.ServerVersion = info.Version
p.ServerPlatform = info.Platform
p.Uptime = formatUptime(info.Uptime)
p.MaxClients = info.MaxClients
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "stats.html", statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges})
s.render(w, "stats.html", p)
}
func (s *Server) statsLive(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("statslive|"+ip, 60, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
current, err := s.ts.CountOnline(ctx)
if err != nil {
ctx2, cancel2 := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel2()
current, _ = s.rrd.Last(ctx2)
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = fmt.Fprintf(w, `{"current":%d}`, current)
}
func formatUptime(d time.Duration) string {
if d <= 0 {
return "—"
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
switch {
case days > 0:
return fmt.Sprintf("%dd %dh", days, hours)
case hours > 0:
return fmt.Sprintf("%dh %dm", hours, minutes)
default:
return fmt.Sprintf("%dm", minutes)
}
}
// assetHashPattern matches a cache-busted asset request such as
// "app.a1b2c3d4e5.css" and captures the base name, hash and extension.
var assetHashPattern = regexp.MustCompile(`^([a-zA-Z0-9_-]+)\.([0-9a-f]{10})\.(css|js)$`)
// asset serves /assets/{file}. A request matching the hashed pattern is
// served with a one-year immutable cache, but only if the hash still
// matches the currently embedded file (so a stale/forged hash 404s instead
// of being cached). Any other request (e.g. a bare "app.css" during local
// debugging) falls back to the embedded file with no special caching.
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
file := r.PathValue("file")
name := file
cacheable := false
if m := assetHashPattern.FindStringSubmatch(file); m != nil {
base, hash, ext := m[1], m[2], m[3]
candidate := base + "." + ext
if assetHashes[candidate] == hash {
name = candidate
cacheable = true
} else {
http.NotFound(w, r)
return
}
}
data, err := embedded.ReadFile("assets/" + name)
if err != nil {
http.NotFound(w, r)
return
}
switch filepath.Ext(name) {
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
if cacheable {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
_, _ = w.Write(data)
}
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
@@ -130,11 +264,16 @@ func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
return
}
key := strings.TrimSuffix(file, ".png")
theme := "dark"
if strings.HasSuffix(key, "-light") {
theme = "light"
key = strings.TrimSuffix(key, "-light")
}
if key == "" || strings.Contains(key, "/") {
http.NotFound(w, r)
return
}
p, err := s.rrd.Graph(ctx, key)
p, err := s.rrd.Graph(ctx, key, theme)
if err != nil {
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
return
@@ -182,6 +321,7 @@ func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", http.StatusForbidden)
return
}
@@ -227,11 +367,13 @@ func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
log.Printf("403 invalid or expired code: ip=%s path=%s token_err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid or expired code", 403)
return
}
@@ -254,46 +396,103 @@ func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
if err != nil {
log.Printf("403 client verification expired: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "client verification expired", 403)
return
}
name := strings.TrimSpace(r.FormValue("name"))
topic := strings.TrimSpace(r.FormValue("topic"))
password := r.FormValue("password")
maxClientsRaw := strings.TrimSpace(r.FormValue("maxclients"))
codecQualityRaw := strings.TrimSpace(r.FormValue("codec_quality"))
if name == "" {
name = randomChannelName()
}
if err := validateInput(name, topic, password); err != nil {
maxClients, codecQuality, err := parseChannelOptions(maxClientsRaw, codecQualityRaw)
if err == nil {
err = validateInput(name, topic, password)
}
if err != nil {
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: r.FormValue("client_token"), FormToken: s.tokens.formToken(ip.String()), Error: err.Error(), VoiceURL: s.voiceURL()})
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: r.FormValue("client_token"), FormToken: s.tokens.formToken(ip.String()), Error: err.Error(), VoiceURL: s.voiceURL(), Name: name, Topic: topic, MaxClients: maxClientsRaw, CodecQuality: codecQualityRaw})
return
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
defer cancel()
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password)
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password, maxClients, codecQuality)
if err != nil {
log.Printf("channel create failed for client dbid=%d: %v", c.DatabaseID, err)
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), Error: "Nie udało się utworzyć kanału. Sprawdź połączenie z TS3 i spróbuj ponownie.", VoiceURL: s.voiceURL()})
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), Error: "Nie udało się utworzyć kanału. Sprawdź połączenie z TS3 i spróbuj ponownie.", VoiceURL: s.voiceURL(), Name: name, Topic: topic, MaxClients: maxClientsRaw, CodecQuality: codecQualityRaw})
return
}
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, FormToken: s.tokens.formToken(ip.String()), Success: "Kanał został utworzony, uprawnienia nadane, a klient przeniesiony.", ChannelID: cid, VoiceURL: s.voiceURL()})
}
func parseChannelOptions(maxClientsRaw, codecQualityRaw string) (maxClients, codecQuality int, err error) {
if maxClientsRaw != "" {
maxClients, err = strconv.Atoi(maxClientsRaw)
if err != nil || maxClients < 0 || maxClients > 512 {
return 0, 0, fmt.Errorf("Limit użytkowników musi być liczbą 0–512 (0 = bez limitu).")
}
}
if codecQualityRaw != "" {
codecQuality, err = strconv.Atoi(codecQualityRaw)
if err != nil || codecQuality < 1 || codecQuality > 10 {
return 0, 0, fmt.Errorf("Jakość kodeka musi być liczbą 1–10.")
}
}
return maxClients, codecQuality, nil
}
// requestHost returns the hostname the client actually used to reach us.
// Behind a reverse proxy, r.Host is the proxy's own upstream address (e.g.
// 127.0.0.1:8080) unless the proxy explicitly forwards the original Host --
// so when the request comes from a configured TRUSTED_PROXIES peer, prefer
// X-Forwarded-Host if the proxy sent one, falling back to r.Host otherwise.
func (s *Server) requestHost(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if remote := net.ParseIP(strings.TrimSpace(host)); remote != nil {
for _, n := range s.cfg.TrustedProxies {
if n.Contains(remote) {
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); v != "" {
return v
}
break
}
}
}
return r.Host
}
func (s *Server) requirePost(w http.ResponseWriter, r *http.Request, ip string) bool {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", 400)
return false
}
if o := r.Header.Get("Origin"); o != "" {
// Browsers legitimately send "Origin: null" for requests originating
// from a sandboxed iframe without allow-same-origin (our documented
// embed widget, /embed/channel) and in a few redirect/privacy-mode
// edge cases -- it is not attacker-controlled, so treat it the same as
// no Origin header at all. The actual CSRF defense here is the signed,
// time-limited, IP-bound form/client/challenge token, not this header;
// this check is only a defense-in-depth cross-check for the common
// case where a real Origin is present.
if o := r.Header.Get("Origin"); o != "" && o != "null" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
host := s.requestHost(r)
if err != nil || !strings.EqualFold(u.Host, host) {
log.Printf("403 origin mismatch: ip=%s path=%s origin=%q expected_host=%q r.Host=%q x-forwarded-host=%q", ip, r.URL.Path, o, host, r.Host, r.Header.Get("X-Forwarded-Host"))
http.Error(w, "forbidden", 403)
return false
}
@@ -355,8 +554,13 @@ func (s *Server) clientIP(r *http.Request) net.IP {
return remote
}
func (s *Server) voiceURL() string {
return "ts3server://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort)
// voiceURL builds the ts3:// deep link used to launch the TeamSpeak client.
// Returned as template.URL (not a plain string) because html/template's
// default URL sanitizer only allow-lists http/https/mailto and otherwise
// silently rewrites the href to "#ZgotmplZ" -- template.URL marks this
// server-built value as safe to emit verbatim.
func (s *Server) voiceURL() template.URL {
return template.URL("ts3://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort))
}
func (s *Server) tooMany(w http.ResponseWriter) {
w.Header().Set("Retry-After", "60")