diff --git a/.gitignore b/.gitignore index 6c5156b..1999fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ data .env +/bin +/dist +/packages diff --git a/Makefile b/Makefile index 7e0ba60..bc190f4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,26 @@ -.PHONY: test build +.PHONY: test build quick dev deb clean test: go test ./... +# Pelny build produkcyjny: testy + statyczna binarka bez symboli/debug info. build: - CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" -o bin/ts3monolith ./cmd/ts3monolith + CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o bin/ts3monolith ./cmd/ts3monolith + +# Szybka iteracja lokalna: bez testow, bez trimpath/ldflags, z cache'em go +# builda -> najszybsza mozliwa petla edit/build podczas developmentu. +quick: + go build -buildvcs=false -o bin/ts3monolith ./cmd/ts3monolith + +# quick build + natychmiastowe uruchomienie z lokalnym .env (musi istniec, +# patrz .env.example). +dev: quick + @test -f .env || { echo "Brak .env - skopiuj .env.example do .env i uzupelnij sekrety."; exit 1; } + set -a; . ./.env; set +a; ./bin/ts3monolith + +# Pakiet .deb + jednostka systemd (scripts/build-deb.sh), wynik w packages/. +deb: + ./scripts/build-deb.sh + +clean: + rm -rf bin dist packages diff --git a/README.md b/README.md index 5aa2524..73e5aff 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,81 @@ # TS3 monolith (Go) Jedna aplikacja zastępująca stare `sk.linuxiarz.pl` i `ts3stats.linuxiarz.pl`. +Wygląd dashboardu (topbar + sidebar, karty, panele, jasny/ciemny motyw) +jest celowo spójny ze stylem [PostfixAdminRS](https://github.com/postfixadmin/postfixadmin) — +ten sam układ tokenów kolorów, te same komponenty UI. -## Zachowana funkcjonalność +## Funkcjonalność - wykrycie klienta TeamSpeak po IP widzianym przez HTTP i ServerQuery; -- utworzenie permanentnego kanału z nazwą, tematem i opcjonalnym hasłem; +- utworzenie permanentnego kanału z nazwą, tematem, opcjonalnym hasłem, + limitem użytkowników (`channel_maxclients`) i jakością kodeka + (`channel_codec_quality`, 1–10); - `setclientchannelgroup` dla wskazanego `client_database_id`; - `clientmove` wskazanego `clid` do nowego kanału; -- obecna baza RRD `online` i wykresy: 12h, 24h, 72h, tydzień, miesiąc, rok; +- dashboard statystyk: liczba online na żywo (auto-odświeżanie co 60 s przez + `GET /stats/live`), uptime/wersja/platforma serwera (`serverinfo`), + wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — w kolorystyce + dopasowanej do motywu aplikacji (ciemne tło, akcentowa linia, watermark); +- przełącznik jasny/ciemny motyw (zapamiętywany w `localStorage`); - formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie. Przy jednym kliencie pod danym IP wszystko działa automatycznie. Jeżeli kilka klientów TS3 ma ten sam publiczny IP (NAT), aplikacja prosi o wybór nicku i wysyła 6-cyfrowy kod prywatną wiadomością TS3. Dzięki temu nie przenosi przypadkowej osoby i nie nadaje jej Channel Admina. -## Uruchomienie +## Szybki development + +``` +cp .env.example .env # uzupełnij sekrety +make quick # najszybszy build (bez testów/trimpath) -> bin/ts3monolith +make dev # quick build + start z lokalnym .env +make test # go test ./... +make build # pełny build produkcyjny (testy + trimpath + bez symboli) +``` + +## Uruchomienie — Docker 1. Skopiuj `.env.example` do `.env` i ustaw sekrety oraz dozwoloną domenę iframe. -2. Zachowaj `data/linuxiarz.rrd`. To kopia dotychczasowej bazy z archiwum. -3. Zapewnij zapis do katalogu `data` dla UID 10001 przy Dockerze: `chown -R 10001:10001 data`. +2. Zachowaj `data/linuxiarz.rrd` (kopia dotychczasowej bazy z archiwum) albo + zainicjuj nową bazę — patrz sekcja [RRD](#rrd). +3. Zapewnij zapis do katalogu `data` dla UID 10001: `chown -R 10001:10001 data`. 4. Uruchom: `docker compose up -d --build`. 5. Reverse proxy kieruje HTTPS do `127.0.0.1:8080`. +## Uruchomienie — pakiet .deb + systemd + +Alternatywa dla Dockera, zalecana na dedykowanym hoście Debian/Ubuntu: + +``` +make deb # scripts/build-deb.sh -> packages/ts3monolith__amd64.deb +sudo dpkg -i packages/ts3monolith_*.deb +sudo nano /etc/default/ts3monolith # APP_SECRET, TS_QUERY_USER, TS_QUERY_PASSWORD, ... +sudo -u ts3monolith /usr/lib/ts3monolith/init-rrd # tylko przy świeżej instalacji, bez starej bazy +sudo systemctl enable --now ts3monolith +``` + +`scripts/build-deb.sh` buduje wszystko w kontenerze Docker — dokładnie tak +samo jak odpowiadający skrypt w PostfixAdminRS — więc host nie potrzebuje +lokalnie zainstalowanego Go ani żadnych zależności poza samym Dockerem. +Wersja obrazu (`golang:-bookworm`) jest odczytywana z +`go.mod`, a sam moduł nie ma żadnych zewnętrznych zależności, więc build jest +w pełni offline po ściągnięciu obrazu. Wynikowy pakiet trafia do `packages/`. +Pakiet instaluje: + +- `/usr/bin/ts3monolith` — binarka; +- `/etc/default/ts3monolith` — zmienne środowiskowe (conffile); +- `/lib/systemd/system/ts3monolith.service` — jednostka z pełnym hardeningiem + (`ProtectSystem=strict`, `NoNewPrivileges`, `RestrictAddressFamilies`, + `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, ...), takim + samym duchem jak w PostfixAdminRS; +- `/usr/lib/ts3monolith/init-rrd` — jednorazowy skrypt tworzący świeżą bazę + RRD z RRA pokrywającymi wszystkie zakresy wykresów (12h…rok), gdy nie ma + archiwalnego pliku do skopiowania; +- dedykowanego użytkownika systemowego `ts3monolith` (bez loginu, bez home). + +`install.sh` z korzenia repo zostaje jako prostszy, ręczny installer bez +budowania pakietu `.deb` (np. do szybkiego postawienia na hoście bez +uprawnień do `dpkg`). + Przykład iframe: ```html @@ -37,24 +92,38 @@ Przykład iframe: ## TeamSpeak ServerQuery -Najbezpieczniej uruchamiać aplikację na tym samym hoście lub w prywatnej sieci z TS3 i ustawić `TS_QUERY_HOST=127.0.0.1`/adres prywatny. Nie wystawiaj portu 10011 publicznie. Query powinien mieć tylko prawa potrzebne do: `clientlist -ip -uid`, `channelcreate`, `setclientchannelgroup`, `clientmove`, `sendtextmessage`, `channeldelete` (rollback). +Najbezpieczniej uruchamiać aplikację na tym samym hoście lub w prywatnej sieci z TS3 i ustawić `TS_QUERY_HOST=127.0.0.1`/adres prywatny. Nie wystawiaj portu 10011 publicznie. Query powinien mieć tylko prawa potrzebne do: `clientlist -ip -uid`, `channelcreate`, `setclientchannelgroup`, `clientmove`, `sendtextmessage`, `channeldelete` (rollback), `serverinfo` (karty uptime/wersji na dashboardzie). ## RRD Aplikacja używa systemowego `rrdtool`, a nie biblioteki Go, więc istniejący plik RRD pozostaje bez migracji. Co 60 s liczona jest liczba klientów `client_type=0` na wybranym virtual serverze i zapisywana do DS `online`. Błąd ServerQuery nie zapisuje sztucznego zera. +Świeża instalacja bez archiwalnej bazy: `packaging/ts3monolith-init-rrd` +(instalowany jako `/usr/lib/ts3monolith/init-rrd` w pakiecie `.deb`) tworzy +nową bazę `rrdtool create` z RRA pokrywającymi 12h/24h/72h/tydzień/miesiąc/rok +przy kroku 60 s — ten sam layout logiczny co istniejące wykresy aplikacji. + +Wykresy renderowane są w kolorach dopasowanych do motywu dashboardu +(`--panel`/`--field`/`--accent` z `internal/web/assets/app.css`), z cienkim +wypełnieniem pod linią, watermarkiem z datą generacji i `--slope-mode` dla +gładszej linii. Każdy zakres jest renderowany dwukrotnie — `.png` +(ciemna paleta) i `-light.png` (jasna paleta) — a front-end +(`internal/web/assets/app.js`) przełącza `src` na właściwy wariant przy +zmianie motywu, więc wykresy zawsze dopasowane są do aktywnego trybu +jasny/ciemny. + Jeżeli docelowy serwer ma inną architekturę/endianness niż źródłowy x86_64 little-endian, przenieś RRD przez `rrdtool dump`/`rrdtool restore` zamiast kopiowania binarnego pliku. ## Bezpieczeństwo / fingerprinting - brak PHP, frameworków frontendowych, CDN i nagłówka `X-Powered-By`; - standardowy `net/http` nie dodaje nagłówka `Server`; -- CSP z kontrolowanym `frame-ancestors`, HSTS, `nosniff`, restrykcyjne Permissions-Policy; +- CSP z kontrolowanym `frame-ancestors`, HSTS, `nosniff`, restrykcyjne Permissions-Policy, `style-src`/`script-src 'self'` (bez inline — cały CSS/JS w plikach statycznych); - nagłówki proxy są ufane wyłącznie z `TRUSTED_PROXIES`; - stateless HMAC tokeny formularzy i klientów, sprawdzanie `Origin`, limity request body i rate limiting; - sekrety tylko przez środowisko, brak danych dostępowych w repo; - rollback kanału, jeśli nadanie grupy lub przeniesienie klienta się nie powiedzie; -- kontener non-root, `cap_drop: ALL`, `no-new-privileges`, read-only root filesystem; +- kontener non-root, `cap_drop: ALL`, `no-new-privileges`, read-only root filesystem (Docker) albo analogiczny hardening jednostki systemd (pakiet `.deb`); - build z `-trimpath` i usuniętymi symbolami/debug info. Nie da się zagwarantować, że stos technologiczny będzie całkowicie nierozpoznawalny na podstawie zachowania sieci/TLS. Celem jest brak prostych bannerów, wersji i sygnatur frameworka. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d91a54 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.3.0 diff --git a/build-release.sh b/build-release.sh index 18faebc..0f87290 100644 --- a/build-release.sh +++ b/build-release.sh @@ -10,7 +10,7 @@ rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR" go test ./... -CGO_ENABLED=0 GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -ldflags="-s -w -buildid=" -o "$OUT_DIR/$NAME" ./cmd/ts3monolith +CGO_ENABLED=0 GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o "$OUT_DIR/$NAME" ./cmd/ts3monolith chmod 0755 "$OUT_DIR/$NAME" if command -v sha256sum >/dev/null 2>&1; then diff --git a/internal/rrd/rrd.go b/internal/rrd/rrd.go index ffd62e7..7673230 100644 --- a/internal/rrd/rrd.go +++ b/internal/rrd/rrd.go @@ -21,6 +21,28 @@ type Manager struct { mu sync.Mutex } +// Graph colors mirror the dashboard's theme tokens (internal/web/assets/app.css) +// so rendered PNGs sit visually in place inside the panel cards. Each range +// is rendered twice (dark + light) so the client can pick the variant that +// matches the active site theme without round-tripping through rrdtool. +const ( + graphColorPanel = "#181c19" // --panel (dark) + graphColorField = "#121713" // --field (dark plot canvas) + graphColorLine = "#313832" // --line (dark minor grid) + graphColorLine2 = "#485149" // --line2 (dark axis / major grid) + graphColorText = "#edf0ecCC" + graphColorAccent = "#8fb9a4" // --accent (dark) + graphColorAccentFill = "#8fb9a43d" + + graphLightColorPanel = "#ffffff" // --panel (light) + graphLightColorField = "#f7f8f6" // --panel2 (light plot canvas) + graphLightColorLine = "#cbd2cb" // --line (light minor grid) + graphLightColorLine2 = "#aeb8af" // --line2 (light axis / major grid) + graphLightColorText = "#202622CC" + graphLightColorAccent = "#507b67" // --accent (light) + graphLightColorAccentFill = "#507b673d" +) + type Range struct{ Key, Start, Title string } var Ranges = []Range{ @@ -77,7 +99,7 @@ func (m *Manager) Last(ctx context.Context) (int, error) { return int(f + 0.5), err } -func (m *Manager) Graph(ctx context.Context, key string) (string, error) { +func (m *Manager) Graph(ctx context.Context, key, theme string) (string, error) { var rg *Range for i := range Ranges { if Ranges[i].Key == key { @@ -91,7 +113,14 @@ func (m *Manager) Graph(ctx context.Context, key string) (string, error) { if err := os.MkdirAll(m.GraphDir, 0750); err != nil { return "", err } - out := filepath.Join(m.GraphDir, key+".png") + + panel, field, line, line2, text, accent, accentFill := graphColorPanel, graphColorField, graphColorLine, graphColorLine2, graphColorText, graphColorAccent, graphColorAccentFill + suffix := "" + if theme == "light" { + panel, field, line, line2, text, accent, accentFill = graphLightColorPanel, graphLightColorField, graphLightColorLine, graphLightColorLine2, graphLightColorText, graphLightColorAccent, graphLightColorAccentFill + suffix = "-light" + } + out := filepath.Join(m.GraphDir, key+suffix+".png") m.mu.Lock() defer m.mu.Unlock() @@ -100,7 +129,38 @@ func (m *Manager) Graph(ctx context.Context, key string) (string, error) { } def := "DEF:online=" + m.Path + ":online:LAST" - args := []string{"graph", out, "--imgformat", "PNG", "--start", rg.Start, "--end", "now", "--width", "1200", "--height", "320", "--lower-limit", "0", "--alt-autoscale-max", "--title", rg.Title, "--vertical-label", "użytkownicy", "--border", "0", def, "LINE2:online#2F6FED:Online", "GPRINT:online:LAST:Aktualnie\\: %4.0lf", "GPRINT:online:AVERAGE:Średnio\\: %4.0lf", "GPRINT:online:MAX:Maks.\\: %4.0lf\\n"} + watermark := "linuxiarz.pl · " + time.Now().Format("2006-01-02 15:04") + args := []string{ + "graph", out, + "--imgformat", "PNG", + "--start", rg.Start, "--end", "now", + "--width", "1200", "--height", "320", + "--lower-limit", "0", "--alt-autoscale-max", "--rigid", + "--slope-mode", + "--title", rg.Title, + "--vertical-label", "użytkownicy", + "--border", "0", + "--color", "BACK" + panel, + "--color", "CANVAS" + field, + "--color", "FONT" + text, + "--color", "AXIS" + line2, + "--color", "ARROW" + line2, + "--color", "GRID" + line, + "--color", "MGRID" + line2, + "--color", "FRAME" + panel, + "--font", "DEFAULT:0:", + "--font", "TITLE:13:", + "--font", "AXIS:9:", + "--font", "LEGEND:10:", + "--font", "UNIT:9:", + "--watermark", watermark, + def, + "AREA:online" + accentFill, + "LINE2:online" + accent + ":Online", + "GPRINT:online:LAST:Aktualnie\\: %4.0lf", + "GPRINT:online:AVERAGE:Średnio\\: %4.0lf", + "GPRINT:online:MAX:Maks.\\: %4.0lf\\n", + } cmd := exec.CommandContext(ctx, m.Tool, args...) var stderr bytes.Buffer cmd.Stderr = &stderr diff --git a/internal/ts3/query.go b/internal/ts3/query.go index 2d1fbc1..8fb4e88 100644 --- a/internal/ts3/query.go +++ b/internal/ts3/query.go @@ -166,6 +166,55 @@ func (q *Query) CountOnline(ctx context.Context) (int, error) { return len(clients), nil } +// ServerInfo describes the virtual server the query connection selects via +// "use port=...". Only the fields the UI needs are parsed; everything else +// returned by the TS3 server is ignored. +type ServerInfo struct { + Name string + Version string + Platform string + Uptime time.Duration + ClientsOnline int + MaxClients int +} + +func (q *Query) ServerInfo(ctx context.Context) (ServerInfo, error) { + var info ServerInfo + err := q.withConn(ctx, func(qc *queryConn) error { + lines, err := qc.run(ctx, "serverinfo") + if err != nil { + return err + } + for _, line := range lines { + for _, row := range strings.Split(line, "|") { + f := parseFields(row) + if v, ok := f["virtualserver_name"]; ok { + info.Name = v + } + if v, ok := f["virtualserver_version"]; ok { + info.Version = strings.Fields(v)[0] + } + if v, ok := f["virtualserver_platform"]; ok { + info.Platform = v + } + if v, ok := f["virtualserver_uptime"]; ok { + if secs, err := strconv.Atoi(v); err == nil { + info.Uptime = time.Duration(secs) * time.Second + } + } + if v, ok := f["virtualserver_clientsonline"]; ok { + info.ClientsOnline, _ = strconv.Atoi(v) + } + if v, ok := f["virtualserver_maxclients"]; ok { + info.MaxClients, _ = strconv.Atoi(v) + } + } + } + return nil + }) + return info, err +} + func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string) error { return q.withConn(ctx, func(qc *queryConn) error { _, err := qc.run(ctx, "sendtextmessage targetmode=1 target="+strconv.Itoa(clid)+" msg="+escape(message)) @@ -173,7 +222,7 @@ func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string }) } -func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string) (int, error) { +func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string, maxClients, codecQuality int) (int, error) { var cid int err := q.withConn(ctx, func(qc *queryConn) error { clients, err := listClients(ctx, qc) @@ -200,6 +249,14 @@ func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adm if password != "" { cmd += " channel_password=" + escape(password) } + if maxClients > 0 { + cmd += " channel_maxclients=" + strconv.Itoa(maxClients) + " channel_flag_maxclients_unlimited=0" + } else { + cmd += " channel_flag_maxclients_unlimited=1" + } + if codecQuality > 0 { + cmd += " channel_codec_quality=" + strconv.Itoa(codecQuality) + } lines, err := qc.run(ctx, cmd) if err != nil { return err diff --git a/internal/ts3/query_test.go b/internal/ts3/query_test.go index 11c0a33..48fb408 100644 --- a/internal/ts3/query_test.go +++ b/internal/ts3/query_test.go @@ -77,7 +77,7 @@ func TestCreateChannelUsesDatabaseIDAndRefreshesClientID(t *testing.T) { q := &Query{Addr: ln.Addr().String(), User: "u", Password: "p", VoicePort: 9987, Timeout: 2 * time.Second} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - cid, err := q.CreateChannelForClient(ctx, Client{ID: 7, DatabaseID: 42, Nick: "old", UID: "abc", IP: "1.2.3.4"}, 5, "Moj kanal", "temat", "haslo") + cid, err := q.CreateChannelForClient(ctx, Client{ID: 7, DatabaseID: 42, Nick: "old", UID: "abc", IP: "1.2.3.4"}, 5, "Moj kanal", "temat", "haslo", 0, 0) if err != nil { t.Fatal(err) } diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 0269217..4a13595 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -1 +1,177 @@ -:root{color-scheme:dark;--bg:#0b0f14;--surface:#121821;--surface-2:#171f2a;--surface-3:#0f151d;--border:#263140;--border-soft:#1c2531;--text:#f5f7fa;--muted:#91a0b2;--primary:#3274d9;--primary-hover:#3d82eb;--success:#43b581;--danger:#e66a70;--shadow:0 14px 35px rgba(0,0,0,.25);--radius:14px}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}a{color:inherit}.app-shell{min-height:100vh}.topbar{min-height:70px;border-bottom:1px solid var(--border-soft);background:rgba(18,24,33,.96);display:flex;align-items:center;justify-content:space-between;gap:24px;padding:10px max(24px,calc((100vw - 1480px)/2));position:sticky;top:0;z-index:20;backdrop-filter:blur(10px)}.brand{text-decoration:none}.brand span{display:flex;flex-direction:column;line-height:1.2}.brand strong{font-size:15px}.brand small{margin-top:3px;color:var(--muted);font-size:12px}.header-right,.top-actions,.button-row{display:flex;align-items:center;gap:10px}.header-online{height:40px;padding:0 13px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}.header-online strong{color:var(--text);font-size:17px}.online-dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 0 3px rgba(67,181,129,.12);flex:none}.btn{appearance:none;border:1px solid transparent;border-radius:9px;min-height:40px;padding:9px 14px;display:inline-flex;align-items:center;justify-content:center;font:inherit;font-weight:650;text-decoration:none;cursor:pointer;transition:background .16s ease,border-color .16s ease,transform .16s ease}.btn:hover{transform:translateY(-1px)}.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}.btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.btn-ghost{background:var(--surface-2);border-color:var(--border);color:var(--text)}.btn-ghost:hover{background:#1c2633}.btn-large{width:100%;min-height:48px;font-size:15px;margin-top:4px}.grow{flex:1}.container{max-width:1480px;margin:0 auto;padding:30px 24px 48px}.page-intro{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:18px}.eyebrow{margin:0 0 5px;text-transform:uppercase;letter-spacing:.13em;font-size:11px;font-weight:800;color:#7890aa}.page-intro h1,.channel-title h1{margin:0;font-size:clamp(26px,3vw,36px);line-height:1.15;letter-spacing:-.035em}.page-intro p:not(.eyebrow),.channel-title p{margin:8px 0 0;color:var(--muted)}.refresh-status{font-size:12px;color:var(--muted)}.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.graph-card{min-width:0;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:0 8px 26px rgba(0,0,0,.16)}.graph-head{padding:15px 17px 12px;border-bottom:1px solid var(--border-soft)}.graph-head h2{margin:0;font-size:15px;letter-spacing:-.01em}.graph-head p{margin:3px 0 0;font-size:12px;color:var(--muted)}.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--surface-3);cursor:zoom-in;overflow:hidden}.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:3.56/1;object-fit:contain;transition:transform .18s ease,opacity .18s ease}.graph-image-button:hover img{transform:scale(1.015);opacity:.96}.lightbox[hidden]{display:none}.lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px}.lightbox-backdrop{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.82);cursor:zoom-out}.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1500px);max-height:92vh;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:48px 16px 16px;box-shadow:0 24px 80px rgba(0,0,0,.55);overflow:auto}.lightbox-dialog img{display:block;width:100%;height:auto}.lightbox-close{position:absolute;top:10px;right:12px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer}.lightbox-open{overflow:hidden}.channel-page{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--bg)}.channel-shell{width:min(100%,700px)}.channel-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}.channel-topbar{min-height:56px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-soft)}.back-link{color:var(--muted);font-size:13px;text-decoration:none}.back-link:hover{color:var(--text)}.channel-title{padding:28px 28px 20px}.channel-title h1{font-size:28px}.alert{margin:0 28px 20px;padding:13px 15px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);display:flex;flex-direction:column;gap:2px}.alert-success{border-color:rgba(67,181,129,.45)}.alert-danger{border-color:rgba(230,106,112,.55);color:#ffd9db}.empty-state,.selection-box{margin:0 28px 28px;padding:22px;border:1px solid var(--border);background:var(--surface-2);border-radius:12px;text-align:center}.empty-state h2,.selection-box h2{font-size:17px;margin:0 0 5px}.empty-state p,.selection-box p{margin:0 0 18px;color:var(--muted);font-size:13px}.selection-box{text-align:left}.client-list{display:grid;gap:8px}.client-choice{width:100%;display:flex;align-items:center;gap:12px;text-align:left;border:1px solid var(--border);background:var(--surface-3);color:var(--text);padding:10px 12px;border-radius:9px;cursor:pointer}.client-choice:hover{border-color:#3b4a5d;background:#141d27}.client-choice .avatar{width:34px;height:34px;border-radius:8px;background:#253347;display:grid;place-items:center;font-weight:800}.client-choice span:nth-child(2){display:flex;flex-direction:column;flex:1}.client-choice small{color:var(--muted)}.client-badge{margin:0 28px 4px;padding:11px 13px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);display:flex;align-items:center;gap:9px;font-size:13px}.client-badge span:nth-child(2){color:var(--muted)}.channel-form{padding:18px 28px 28px}.field{margin-bottom:16px}.field label{display:block;font-size:13px;font-weight:650;margin-bottom:6px}.field input{width:100%;border:1px solid var(--border);background:var(--surface-3);color:var(--text);border-radius:9px;padding:11px 12px;font:inherit;outline:none}.field input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(50,116,217,.14)}.field input::placeholder{color:#647487}.field small{display:block;color:var(--muted);font-size:11px;margin-top:5px}@media(max-width:1060px){.graphs{grid-template-columns:1fr}.graph-image-button img{aspect-ratio:auto;min-height:260px}}@media(max-width:720px){.topbar{align-items:flex-start;padding:12px 16px;gap:12px}.brand small{display:none}.header-right{align-items:flex-end;flex-direction:column}.header-online{height:34px}.top-actions .btn{min-height:36px;padding:7px 10px;font-size:13px}.container{padding:18px 12px 34px}.page-intro{align-items:flex-start;flex-direction:column;gap:8px}.graph-head{padding:13px 14px}.graph-image-button{overflow-x:auto}.graph-image-button img{width:auto;min-width:900px;max-width:none;min-height:0;aspect-ratio:auto}.lightbox{padding:10px}.lightbox-dialog{width:100%;padding:46px 8px 8px}.channel-page{display:block;padding:0}.channel-shell{width:100%}.channel-card{min-height:100vh;border-radius:0;border-left:0;border-right:0}.channel-title{padding:23px 18px 17px}.channel-title h1{font-size:24px}.alert,.empty-state,.selection-box,.client-badge{margin-left:18px;margin-right:18px}.channel-form{padding:18px}.button-row{flex-direction:column}.button-row .btn{width:100%}} +/* === tokens ============================================================ */ +:root{ + color-scheme:dark; + --bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310; + --line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f; + --accent:#8fb9a4;--accent2:#a4c9b6;--accentink:#102018;--danger:#e18f8a;--warn:#d0ad70;--success:#a7c892; + --field:#121713;--selected:#28352e;--hover:#202621;--shadow:rgba(0,0,0,.32); + --font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + --mono:ui-monospace,SFMono-Regular,Consolas,monospace;--r:4px;--control:38px +} +html[data-theme=light]{ + color-scheme:light; + --bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9; + --line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d; + --accent:#507b67;--accent2:#416b58;--accentink:#fff;--danger:#a94c48;--warn:#8a6930;--success:#587a49; + --field:#fff;--selected:#dce8e0;--hover:#e8ede8;--shadow:rgba(31,39,33,.16) +} + +/* === base =============================================================== */ +*{box-sizing:border-box;scrollbar-color:var(--line2) var(--panel);scrollbar-width:thin} +html,body{min-height:100%} +body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--font);-webkit-font-smoothing:antialiased} +a{color:inherit;text-decoration:none} +button,input,select,textarea{font:inherit;color:inherit} +input,select,textarea{width:100%;min-height:var(--control);border:1px solid var(--line2);border-radius:var(--r);background:var(--field);padding:8px 11px;outline:0} +input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)} +input::placeholder{color:var(--subtle)} +button{cursor:pointer} +.icon{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto} +.muted{color:var(--muted)} +.mono{font-family:var(--mono)} + +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:var(--control);padding:7px 14px;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel2);color:var(--text);font-weight:650;white-space:nowrap} +.btn:hover{border-color:var(--accent)} +.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accentink)} +.btn.primary:hover{background:var(--accent2);border-color:var(--accent2)} +.btn.ghost{background:transparent;border-color:transparent} +.btn.danger{color:var(--danger)} +.btn.full{width:100%} +.btn.lg{min-height:46px;font-size:15px} + +.badge{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:11px;color:var(--muted)} +.badge.ok{color:var(--success);border-color:color-mix(in srgb,var(--success) 40%,var(--line))} +.badge .dot{width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,currentColor 18%,transparent)} + +.alert{margin:0 0 16px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);display:flex;align-items:flex-start;gap:10px;font-size:13px;line-height:1.5} +.alert .icon{margin-top:1px;color:var(--muted)} +.alert.success{border-color:color-mix(in srgb,var(--success) 45%,var(--line));color:var(--success)} +.alert.success .icon{color:var(--success)} +.alert.danger{border-color:color-mix(in srgb,var(--danger) 45%,var(--line));color:var(--danger)} +.alert.danger .icon{color:var(--danger)} +.alert strong{color:var(--text)} + +/* theme toggle, shared by every page */ +.theme-toggle{width:36px;height:36px;min-height:36px;padding:0;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel2);color:var(--accent2);outline:1px solid color-mix(in srgb,var(--text) 18%,transparent);flex:0 0 auto} +.theme-toggle:hover{background:var(--hover);border-color:var(--accent)} +.theme-toggle .icon{width:18px;height:18px;display:block;margin:auto} +.theme-toggle .ic-moon{display:none} +html[data-theme=light] .theme-toggle .ic-sun{display:none} +html[data-theme=light] .theme-toggle .ic-moon{display:block} + +/* === dashboard shell (stats page) ======================================= */ +.shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)} +.topbar{position:relative;display:grid;grid-template-columns:240px 1fr auto;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)} +.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px} +.brand svg{color:var(--accent);width:24px;height:24px} +.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px} +.header-online strong{color:var(--text);font-size:16px} +.top-actions{display:flex;align-items:center;gap:8px} +.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;background:transparent} +.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1.1s ease-in-out infinite} +.route-progress[hidden]{display:none} +@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}} + +.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)} +.sidebar{min-height:0;padding:14px 10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto} +.nav{display:grid;gap:3px} +.nav a{display:flex;align-items:center;gap:10px;height:38px;padding:0 11px;border:1px solid transparent;border-radius:var(--r);color:var(--muted)} +.nav a:hover{background:var(--hover);color:var(--text)} +.nav a.active{background:var(--selected);color:var(--text);border-color:var(--line)} +.nav a svg{width:17px;height:17px} +.nav-section{margin:16px 11px 6px;color:var(--subtle);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase} +.nav-server{display:flex;align-items:center;gap:9px;height:30px;padding:0 11px;color:var(--muted);font-size:12px} +.nav-server svg{width:15px;height:15px;flex:0 0 auto} +.nav-server span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.icon-sprite{display:none} + +.content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px} +.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap} +.page-head h1{margin:0;font-size:24px;letter-spacing:-.3px} +.page-head p{margin:4px 0 0;color:var(--muted)} +.eyebrow{margin:0 0 6px;text-transform:uppercase;letter-spacing:.12em;font-size:11px;font-weight:750;color:var(--subtle)} + +.cards{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;margin-bottom:22px} +.card{padding:16px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px;align-items:start} +.card>.icon{width:22px;height:22px;color:var(--accent);margin-top:2px} +.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.07em} +.card .v{margin-top:5px;font-size:24px;font-weight:720;overflow-wrap:anywhere} +.card .vs{margin-top:5px;font-size:14px;font-weight:650;overflow-wrap:anywhere} + +.section-title{margin:0 2px 10px;color:var(--muted);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase} +.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden} +.panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;min-height:44px;padding:0 13px;border-bottom:1px solid var(--line)} +.panel-head h2{margin:0;font-size:14px;font-weight:650} +.panel-head p{margin:0;font-size:11px;color:var(--muted)} + +.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} +.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in;overflow:hidden} +.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:3.56/1;object-fit:contain;transition:transform .18s ease,opacity .18s ease} +.graph-image-button:hover img{transform:scale(1.012);opacity:.96} + +.lightbox[hidden]{display:none} +.lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px} +.lightbox-backdrop{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.78);cursor:zoom-out} +.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1500px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 16px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto} +.lightbox-dialog img{display:block;width:100%;height:auto} +.lightbox-close{position:absolute;top:10px;right:12px;display:inline-flex;gap:6px;align-items:center} +.lightbox-open{overflow:hidden} + +/* === channel / verify (standalone card pages) ========================== */ +.channel-view{min-height:100vh;display:grid;place-items:center;padding:28px} +.channel-shell{width:min(100%,640px)} +.channel-card{border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow);overflow:hidden} +.channel-topbar{min-height:50px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:var(--panel2)} +.channel-topbar-right{display:flex;align-items:center;gap:10px} +.channel-topbar a{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;font-weight:650} +.channel-topbar a:hover{color:var(--text)} +.channel-topbar a svg{width:14px;height:14px} +.channel-title{padding:22px 24px 6px;display:flex;gap:12px;align-items:flex-start} +.channel-title svg{width:30px;height:30px;color:var(--accent);margin-top:2px} +.channel-title h1{margin:0;font-size:21px;letter-spacing:-.2px} +.channel-title p{margin:6px 0 0;color:var(--muted);font-size:13px} +.channel-body{padding:18px 24px 24px} +.empty-state,.selection-box{margin:0 0 18px;padding:20px;border:1px solid var(--line);background:var(--panel2);border-radius:var(--r);text-align:center} +.empty-state h2,.selection-box h2{font-size:15px;margin:0 0 6px} +.empty-state p,.selection-box p{margin:0 0 16px;color:var(--muted);font-size:13px} +.selection-box{text-align:left} +.client-list{display:grid;gap:7px} +.client-choice{width:100%;display:flex;align-items:center;gap:11px;text-align:left;border:1px solid var(--line);background:var(--field);color:var(--text);padding:10px 12px;border-radius:var(--r)} +.client-choice:hover{border-color:var(--accent)} +.client-choice .avatar{width:32px;height:32px;border-radius:var(--r);background:var(--selected);color:var(--accent2);display:grid;place-items:center;font-weight:800;flex:0 0 auto} +.client-choice .meta{display:flex;flex-direction:column;flex:1;min-width:0} +.client-choice small{color:var(--muted)} +.client-badge{margin:0 0 16px;padding:11px 13px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);display:flex;align-items:center;gap:9px;font-size:13px} +.client-badge .dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 18%,transparent);flex:0 0 auto} +.client-badge span{color:var(--muted)} + +.button-row{display:flex;gap:8px;flex-wrap:wrap} +.button-row .btn{flex:1} +.field{margin-bottom:15px} +.field label{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:650;margin-bottom:6px;color:var(--text)} +.field label svg{width:14px;height:14px;color:var(--muted)} +.field small{display:block;color:var(--subtle);font-size:11px;margin-top:5px} +.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px} +.form-grid .wide{grid-column:1/-1} + +/* === responsive ========================================================= */ +@media(max-width:1060px){ + .cards{grid-template-columns:repeat(2,minmax(140px,1fr))} + .graphs{grid-template-columns:1fr} + .graph-image-button img{aspect-ratio:auto;min-height:260px} +} +@media(max-width:860px){ + .topbar{grid-template-columns:auto 1fr auto} + .body{grid-template-columns:1fr} + .sidebar{display:none} +} +@media(max-width:620px){ + .cards{grid-template-columns:1fr} + .form-grid{grid-template-columns:1fr} + .content{padding:18px 14px 36px} + .lightbox{padding:10px} + .lightbox-dialog{width:100%;padding:46px 8px 8px} + .channel-view{padding:0} + .channel-shell{width:100%} + .channel-card{min-height:100vh;border-radius:0;border-left:0;border-right:0} +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 397f6fa..e353f58 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -1,17 +1,69 @@ (function () { + 'use strict'; + + // --- theme (dark/light), persisted per browser ------------------------- + var THEME_KEY = 'ts3monolith-theme'; + var root = document.documentElement; + var toggle = document.querySelector('.theme-toggle'); + var graphs = document.querySelectorAll('.js-live-graph'); + var openers = document.querySelectorAll('.js-graph-open'); + + function currentTheme() { + return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; + } + + // RRD graphs are static PNGs rendered server-side in both a dark and a + // light palette (see internal/rrd/rrd.go); swap to the variant matching + // the active theme so panel graphs never clash with the page around them. + function applyGraphTheme(theme) { + var stamp = Date.now(); + graphs.forEach(function (img) { + var src = theme === 'light' ? img.dataset.srcLight : img.dataset.src; + if (src) img.src = src + '?v=' + stamp; + }); + openers.forEach(function (button) { + var full = theme === 'light' ? button.getAttribute('data-full-light') : button.getAttribute('data-full'); + if (full) button.setAttribute('data-full-active', full); + }); + } + + function applyTheme(mode) { + if (mode === 'light') root.setAttribute('data-theme', 'light'); + else root.removeAttribute('data-theme'); + applyGraphTheme(mode === 'light' ? 'light' : 'dark'); + } + try { + var saved = window.localStorage.getItem(THEME_KEY); + if (saved) applyTheme(saved); + else applyGraphTheme(currentTheme()); + } catch (e) { + applyGraphTheme(currentTheme()); + } + + if (toggle) { + toggle.addEventListener('click', function () { + var next = currentTheme() === 'light' ? 'dark' : 'light'; + applyTheme(next); + try { window.localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ } + }); + } + + // --- live stats + graph refresh ----------------------------------------- var count = document.getElementById('online-count'); var status = document.getElementById('refresh-status'); - var graphs = document.querySelectorAll('.js-live-graph'); - var lightbox = document.getElementById('graph-lightbox'); - var lightboxImage = document.getElementById('lightbox-image'); - var openers = document.querySelectorAll('.js-graph-open'); - var closeButtons = document.querySelectorAll('[data-lightbox-close]'); + var progress = document.getElementById('route-progress'); + + function setBusy(busy) { + if (progress) progress.hidden = !busy; + } function refresh() { + setBusy(true); if (status) status.textContent = 'Odświeżanie…'; + var done = Promise.resolve(); if (count) { - fetch('/stats/live', {cache: 'no-store'}) + done = fetch('/stats/live', { cache: 'no-store' }) .then(function (response) { if (!response.ok) throw new Error('stats'); return response.json(); @@ -25,13 +77,21 @@ }); } - var stamp = Date.now(); - graphs.forEach(function (img) { - var src = img.dataset.src; - if (src) img.src = src + '?v=' + stamp; - }); + applyGraphTheme(currentTheme()); + + done.then(function () { setBusy(false); }); } + if (count || graphs.length) { + window.setInterval(refresh, 60000); + } + + // --- graph lightbox ------------------------------------------------------- + var lightbox = document.getElementById('graph-lightbox'); + var lightboxImage = document.getElementById('lightbox-image'); + var openers = document.querySelectorAll('.js-graph-open'); + var closeButtons = document.querySelectorAll('[data-lightbox-close]'); + function openLightbox(src) { if (!lightbox || !lightboxImage) return; lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now(); @@ -50,7 +110,7 @@ openers.forEach(function (button) { button.addEventListener('click', function () { - var src = button.getAttribute('data-full'); + var src = button.getAttribute('data-full-active') || button.getAttribute('data-full'); if (src) openLightbox(src); }); }); @@ -62,6 +122,4 @@ document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox(); }); - - window.setInterval(refresh, 60000); })(); diff --git a/internal/web/server.go b/internal/web/server.go index a2970f3..cc5575a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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/ 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") diff --git a/internal/web/templates/channel.html b/internal/web/templates/channel.html index 3dbaadf..e2cf690 100644 --- a/internal/web/templates/channel.html +++ b/internal/web/templates/channel.html @@ -4,99 +4,126 @@ Utwórz kanał - + + - -
-
- - -
-
-

TeamSpeak 3

-

Utwórz swój kanał

-

Aplikacja wykryje Twojego klienta, utworzy kanał, nada prawa i przeniesie Cię automatycznie.

-
-
- - {{if .Error}}
{{.Error}}
{{end}} - - {{if .Success}} -
- Kanał został utworzony. - {{.Success}} ID kanału: {{.ChannelID}} -
- - {{else}} - {{if eq (len .Clients) 0}}{{if not .Client}} -
-

Nie wykryto klienta TS3

-

Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.

-
- Połącz z TeamSpeak - Odśwież + +{{template "icons" .}} +
+
+
+
+ Statystyki +
+ Połącz z TS3 +
- {{end}}{{end}} - {{if gt (len .Clients) 1}} -
-

Wybierz swojego klienta

-

Pod tym samym adresem IP jest kilka osób. Kod potwierdzający przyjdzie jako prywatna wiadomość na TS3.

-
- {{range .Clients}} -
- - - +
+ +
+

TeamSpeak 3

+

Utwórz swój kanał

+

Aplikacja wykryje Twojego klienta, utworzy kanał, nada prawa i przeniesie Cię automatycznie.

+
+
+ +
+ {{if .Error}}
{{.Error}}
{{end}} + + {{if .Success}} +
+ + Kanał został utworzony.
{{.Success}} ID kanału: {{.ChannelID}}
+
+ + {{else}} + {{if eq (len .Clients) 0}}{{if not .Client}} +
+

Nie wykryto klienta TS3

+

Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.

+ +
+ {{end}}{{end}} + + {{if gt (len .Clients) 1}} +
+

Wybierz swojego klienta

+

Pod tym samym adresem IP jest kilka osób. Kod potwierdzający przyjdzie jako prywatna wiadomość na TS3.

+
+ {{range .Clients}} + + + + + + {{end}} +
+
+ {{end}} + + {{if .Client}} +
+ + Wykryty klient + {{.Client.Nick}} +
+ +
+ + + +
+ + + Pozostaw puste, aby wygenerować nazwę automatycznie. +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + + Wyższa wartość oznacza lepszą jakość głosu, ale większe zużycie pasma. +
+ +
+ {{end}} {{end}} -
- {{end}} - - {{if .Client}} -
- - Wykryty klient - {{.Client.Nick}} -
- -
- - - -
- - - Pozostaw puste, aby wygenerować nazwę automatycznie. -
- -
- - -
- -
- - -
- - -
- {{end}} - {{end}} -
+
+
{{end}} diff --git a/internal/web/templates/icons.html b/internal/web/templates/icons.html new file mode 100644 index 0000000..0f17f2c --- /dev/null +++ b/internal/web/templates/icons.html @@ -0,0 +1,23 @@ +{{define "icons"}} + + + + + + + + + + + + + + + + + + + + + +{{end}} diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html index 46192fc..29c5671 100644 --- a/internal/web/templates/stats.html +++ b/internal/web/templates/stats.html @@ -4,61 +4,98 @@ TeamSpeak — statystyki - - + + -
+{{template "icons" .}} +
- - TeamSpeakPanel serwera + + + Panel serwera - -
-
- - Online - {{.Current}} -
- +
+ Online + {{.Current}} + Dane aktualne
+ +
-
-
-
-

Statystyki serwera

-

Aktywność TeamSpeak 3

-

Historia liczby użytkowników połączonych z serwerem.

+
+
+ -
- {{range .Ranges}} -
-
-
-

{{.Title}}

-

Liczba użytkowników online

-
+
+
+
+

Statystyki serwera

+

Aktywność TeamSpeak 3

+

Historia liczby użytkowników połączonych z serwerem.

- -
- {{end}} -
-
+ + +
+
+ +
Online teraz
{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}} / {{.MaxClients}}{{end}}{{end}}
+
+
+ +
Uptime
{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}
+
+
+ +
Wersja serwera
{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}
+
+
+ +
Platforma
{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}
+
+
+ +

Wykresy obecności

+
+ {{range .Ranges}} +
+
+
+

{{.Title}}

+

Liczba użytkowników online

+
+
+ +
+ {{end}} +
+ +
diff --git a/internal/web/templates/verify.html b/internal/web/templates/verify.html index 455f202..407a2da 100644 --- a/internal/web/templates/verify.html +++ b/internal/web/templates/verify.html @@ -1,3 +1,51 @@ {{define "verify.html"}} -Weryfikacja TS3 -

Potwierdź klienta TS3

Kod został wysłany prywatnie do {{.Client.Nick}}.

{{end}} + + + + +Weryfikacja TS3 + + + + +{{template "icons" .}} +
+
+
+
+ Statystyki +
+ Połącz z TS3 + +
+
+ +
+ +
+

Weryfikacja

+

Potwierdź klienta TS3

+

Kod został wysłany prywatnie do {{.Client.Nick}}.

+
+
+ +
+
+ + +
+ + + Kod jest ważny 5 minut. +
+ +
+
+
+
+
+ +{{end}} diff --git a/internal/web/tokens.go b/internal/web/tokens.go index ba47576..9e0172d 100644 --- a/internal/web/tokens.go +++ b/internal/web/tokens.go @@ -60,8 +60,14 @@ func (m *tokenManager) verify(tok, kind, ip string) (tokenPayload, error) { if err = json.Unmarshal(raw, &p); err != nil { return p, err } - if p.Kind != kind || p.IP != ip || time.Now().Unix() > p.Exp { - return p, errors.New("expired or mismatched token") + if p.Kind != kind { + return p, fmt.Errorf("token kind mismatch: got %q want %q", p.Kind, kind) + } + if p.IP != ip { + return p, fmt.Errorf("token ip mismatch: token_ip=%q request_ip=%q", p.IP, ip) + } + if now := time.Now().Unix(); now > p.Exp { + return p, fmt.Errorf("token expired %ds ago", now-p.Exp) } return p, nil } diff --git a/packaging/ts3monolith-init-rrd b/packaging/ts3monolith-init-rrd new file mode 100755 index 0000000..7c29274 --- /dev/null +++ b/packaging/ts3monolith-init-rrd @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +# Tworzy nowa baze RRD dla ts3monolith, jesli jeszcze nie istnieje. Uzyj tego +# przy swiezej instalacji (brak starego pliku z archiwum). Uruchom jako +# uzytkownik ts3monolith, np.: +# sudo -u ts3monolith /usr/lib/ts3monolith/init-rrd +# +# Zmienne RRD_PATH / RRDTOOL mozna nadpisac w srodowisku, domyslnie czyta +# te same wartosci co /etc/default/ts3monolith. + +RRD_PATH="${RRD_PATH:-/var/lib/ts3monolith/online.rrd}" +RRDTOOL="${RRDTOOL:-rrdtool}" + +if [ -e "$RRD_PATH" ]; then + echo "RRD juz istnieje: $RRD_PATH (pomijam)" + exit 0 +fi + +command -v "$RRDTOOL" >/dev/null 2>&1 || { echo "error: $RRDTOOL nie znaleziony w PATH" >&2; exit 1; } + +install -d -m 0750 "$(dirname "$RRD_PATH")" + +# step=60s. RRA pokrywaja wszystkie zakresy wykresow aplikacji: +# LAST 1x60s x4500 ~ 75h -> 12h / 24h / 72h +# AVG 5x60s x2016 ~ 7d -> tydzien +# AVG 30x60s x1488 ~ 31d -> miesiac +# AVG 360x60s x1460 ~ 365d -> rok +"$RRDTOOL" create "$RRD_PATH" \ + --start now-1h --step 60 \ + DS:online:GAUGE:120:0:U \ + RRA:LAST:0.5:1:4500 \ + RRA:AVERAGE:0.5:5:2016 \ + RRA:MAX:0.5:5:2016 \ + RRA:AVERAGE:0.5:30:1488 \ + RRA:MAX:0.5:30:1488 \ + RRA:AVERAGE:0.5:360:1460 \ + RRA:MAX:0.5:360:1460 + +echo "Utworzono nowa baze RRD: $RRD_PATH" diff --git a/packaging/ts3monolith.default b/packaging/ts3monolith.default new file mode 100644 index 0000000..5a1438e --- /dev/null +++ b/packaging/ts3monolith.default @@ -0,0 +1,29 @@ +# Zmienne srodowiskowe ts3monolith (EnvironmentFile uslugi systemd). +# Po edycji: systemctl restart ts3monolith +# +# === WYMAGANE przed pierwszym uruchomieniem ================================ +APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET +TS_QUERY_USER=CHANGE_ME +TS_QUERY_PASSWORD=CHANGE_ME + +# === Siec / bezpieczenstwo =================================================== +LISTEN_ADDR=127.0.0.1:8080 +HTTPS_ONLY=true +# Originy (tylko schemat+host), ktore moga osadzic /embed/channel w