v0.3.0-prod
This commit is contained in:
1 parent
387933957e
commit
677a0e4d2c
20 files changed
+1239
-180
No files matched your search
+222
-18
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user