Files
2026-10-30 10:56:13 +01:00

373 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"ts3monolith/internal/config"
"ts3monolith/internal/rrd"
"ts3monolith/internal/ts3"
)
//go:embed templates/*.html assets/*
var embedded embed.FS
type Server struct {
cfg config.Config
ts *ts3.Query
rrd *rrd.Manager
tpl *template.Template
tokens *tokenManager
limit *limiter
}
type channelPage struct {
ClientIP string
Clients []ts3.Client
Client *ts3.Client
ClientToken string
FormToken string
ChallengeToken string
Error string
Success string
ChannelID int
VoiceURL string
}
type statsPage struct {
Current int
VoiceURL string
Ranges []rrd.Range
}
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
tpl, err := template.ParseFS(embedded, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{cfg: cfg, ts: tq, rrd: rm, tpl: tpl, tokens: newTokenManager(cfg.AppSecret), limit: newLimiter()}, nil
}
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 /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
mux.HandleFunc("GET /stats", s.stats)
mux.HandleFunc("GET /graph/{file}", s.graph)
mux.HandleFunc("GET /channel", s.channel)
mux.HandleFunc("GET /embed/channel", s.channel)
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
mux.HandleFunc("POST /channel/verify/complete", s.verifyComplete)
mux.HandleFunc("POST /channel/create", s.createChannel)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
return s.security(mux)
}
func (s *Server) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
if s.cfg.HTTPSOnly {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
frame := "'none'"
if strings.HasPrefix(r.URL.Path, "/embed/channel") || strings.HasPrefix(r.URL.Path, "/channel/") {
frame = strings.Join(s.cfg.FrameAncestors, " ")
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors "+frame+"; form-action 'self'; img-src 'self'; style-src 'self'; script-src 'self'")
next.ServeHTTP(w, r)
})
}
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("stats|"+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")
s.render(w, "stats.html", statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges})
}
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
file := r.PathValue("file")
if !strings.HasSuffix(file, ".png") {
http.NotFound(w, r)
return
}
key := strings.TrimSuffix(file, ".png")
if key == "" || strings.Contains(key, "/") {
http.NotFound(w, r)
return
}
p, err := s.rrd.Graph(ctx, key)
if err != nil {
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Cache-Control", "public, max-age=60")
http.ServeFile(w, r, p)
}
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "invalid client address", http.StatusBadRequest)
return
}
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
p := channelPage{ClientIP: ip.String(), Clients: clients, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
if err != nil {
p.Error = "Nie udało się sprawdzić połączenia z TeamSpeak."
}
if len(clients) == 1 {
p.Client = &clients[0]
p.ClientToken = s.tokens.clientToken(ip.String(), clients[0])
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "channel.html", p)
}
func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
http.Error(w, "invalid form", http.StatusForbidden)
return
}
clid, _ := strconv.Atoi(r.FormValue("clid"))
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
if err != nil {
http.Error(w, "verification unavailable", 503)
return
}
var selected *ts3.Client
for i := range clients {
if clients[i].ID == clid {
selected = &clients[i]
break
}
}
if selected == nil {
http.Error(w, "client unavailable", 409)
return
}
challenge, code, _ := s.tokens.challengeToken(ip.String(), *selected)
if err := s.ts.SendPrivateMessage(ctx, selected.ID, "Kod weryfikacyjny strony tworzenia kanału: "+code+". Kod jest ważny 5 minut."); err != nil {
http.Error(w, "could not send verification", 503)
return
}
p := channelPage{ClientIP: ip.String(), Client: selected, ChallengeToken: challenge, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "verify.html", p)
}
func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
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) {
http.Error(w, "invalid or expired code", 403)
return
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
p := channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "channel.html", p)
}
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
if err != nil {
http.Error(w, "client verification expired", 403)
return
}
name := strings.TrimSpace(r.FormValue("name"))
topic := strings.TrimSpace(r.FormValue("topic"))
password := r.FormValue("password")
if name == "" {
name = randomChannelName()
}
if err := validateInput(name, topic, password); 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()})
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)
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()})
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 (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 != "" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
http.Error(w, "forbidden", 403)
return false
}
}
return true
}
func validateInput(name, topic, password string) error {
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 40 {
return fmt.Errorf("Nazwa kanału musi mieć 1–40 znaków.")
}
if utf8.RuneCountInString(topic) > 255 {
return fmt.Errorf("Temat może mieć maksymalnie 255 znaków.")
}
if utf8.RuneCountInString(password) > 64 {
return fmt.Errorf("Hasło może mieć maksymalnie 64 znaki.")
}
for _, v := range []string{name, topic, password} {
if strings.ContainsRune(v, '\x00') {
return fmt.Errorf("Nieprawidłowe dane wejściowe.")
}
}
return nil
}
func randomChannelName() string { return "channel-" + randomNonce()[:10] }
func (s *Server) clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
remote := net.ParseIP(strings.TrimSpace(host))
if remote == nil {
return nil
}
trusted := false
for _, n := range s.cfg.TrustedProxies {
if n.Contains(remote) {
trusted = true
break
}
}
if !trusted {
return remote
}
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
if ip := net.ParseIP(v); ip != nil {
return ip
}
}
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
if ip := net.ParseIP(strings.TrimSpace(strings.Split(v, ",")[0])); ip != nil {
return ip
}
}
return remote
}
func (s *Server) voiceURL() string {
return "ts3server://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort)
}
func (s *Server) tooMany(w http.ResponseWriter) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many requests", http.StatusTooManyRequests)
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template error: %v", err)
}
}
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }