first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 10:56:13 +01:00
commit fccfe2a4c8
26 files changed
+1819

No files matched your search

+1
View File
@@ -0,0 +1 @@
: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:Line truncated
+67
View File
@@ -0,0 +1,67 @@
(function () {
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]');
function refresh() {
if (status) status.textContent = 'Odświeżanie…';
if (count) {
fetch('/stats/live', {cache: 'no-store'})
.then(function (response) {
if (!response.ok) throw new Error('stats');
return response.json();
})
.then(function (data) {
if (typeof data.current === 'number') count.textContent = String(data.current);
if (status) status.textContent = 'Dane aktualne';
})
.catch(function () {
if (status) status.textContent = 'Nie udało się odświeżyć';
});
}
var stamp = Date.now();
graphs.forEach(function (img) {
var src = img.dataset.src;
if (src) img.src = src + '?v=' + stamp;
});
}
function openLightbox(src) {
if (!lightbox || !lightboxImage) return;
lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now();
lightbox.hidden = false;
lightbox.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open');
}
function closeLightbox() {
if (!lightbox || !lightboxImage) return;
lightbox.hidden = true;
lightbox.setAttribute('aria-hidden', 'true');
lightboxImage.src = '';
document.body.classList.remove('lightbox-open');
}
openers.forEach(function (button) {
button.addEventListener('click', function () {
var src = button.getAttribute('data-full');
if (src) openLightbox(src);
});
});
closeButtons.forEach(function (button) {
button.addEventListener('click', closeLightbox);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox();
});
window.setInterval(refresh, 60000);
})();
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"sync"
"time"
)
type rateEntry struct {
since time.Time
n int
}
type limiter struct {
mu sync.Mutex
entries map[string]rateEntry
}
func newLimiter() *limiter { return &limiter{entries: make(map[string]rateEntry)} }
func (l *limiter) allow(key string, max int, window time.Duration) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e.since.IsZero() || now.Sub(e.since) >= window {
l.entries[key] = rateEntry{since: now, n: 1}
return true
}
if e.n >= max {
return false
}
e.n++
l.entries[key] = e
if len(l.entries) > 10000 {
for k, v := range l.entries {
if now.Sub(v.since) > 2*window {
delete(l.entries, k)
}
}
}
return true
}
+372
View File
@@ -0,0 +1,372 @@
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) }
+102
View File
@@ -0,0 +1,102 @@
{{define "channel.html"}}<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Utwórz kanał</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body class="channel-page">
<main class="channel-shell">
<section class="channel-card">
<div class="channel-topbar">
<a class="back-link" href="/stats">Statystyki
</a>
<a class="back-link" href="{{.VoiceURL}}" target="_top">Połącz z TS3</a>
</div>
<div class="channel-title">
<div>
<p class="eyebrow">TeamSpeak 3</p>
<h1>Utwórz swój kanał</h1>
<p>Aplikacja wykryje Twojego klienta, utworzy kanał, nada prawa i przeniesie Cię automatycznie.</p>
</div>
</div>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Success}}
<div class="alert alert-success">
<strong>Kanał został utworzony.</strong>
<span>{{.Success}} ID kanału: {{.ChannelID}}</span>
</div>
<div class="button-row">
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Otwórz TeamSpeak</a>
<a class="btn btn-ghost" href="/stats">Statystyki</a>
</div>
{{else}}
{{if eq (len .Clients) 0}}{{if not .Client}}
<div class="empty-state">
<h2>Nie wykryto klienta TS3</h2>
<p>Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.</p>
<div class="button-row">
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Połącz z TeamSpeak</a>
<a class="btn btn-ghost" href="">Odśwież</a>
</div>
</div>
{{end}}{{end}}
{{if gt (len .Clients) 1}}
<div class="selection-box">
<h2>Wybierz swojego klienta</h2>
<p>Pod tym samym adresem IP jest kilka osób. Kod potwierdzający przyjdzie jako prywatna wiadomość na TS3.</p>
<div class="client-list">
{{range .Clients}}
<form method="post" action="/channel/verify/start">
<input type="hidden" name="form_token" value="{{$.FormToken}}">
<input type="hidden" name="clid" value="{{.ID}}">
<button type="submit" class="client-choice">
<span class="avatar" aria-hidden="true">{{printf "%.1s" .Nick}}</span>
<span><strong>{{.Nick}}</strong><small>Wyślij kod weryfikacyjny</small></span></button>
</form>
{{end}}
</div>
</div>
{{end}}
{{if .Client}}
<div class="client-badge">
<span class="online-dot" aria-hidden="true"></span>
<span>Wykryty klient</span>
<strong>{{.Client.Nick}}</strong>
</div>
<form method="post" action="/channel/create" class="channel-form">
<input type="hidden" name="form_token" value="{{.FormToken}}">
<input type="hidden" name="client_token" value="{{.ClientToken}}">
<div class="field">
<label for="channel-name">Nazwa kanału</label>
<input id="channel-name" name="name" maxlength="40" autocomplete="off" placeholder="Np. Pokój Mateusza">
<small>Pozostaw puste, aby wygenerować nazwę automatycznie.</small>
</div>
<div class="field">
<label for="channel-password">Hasło</label>
<input id="channel-password" name="password" maxlength="64" type="password" autocomplete="new-password" placeholder="Opcjonalne">
</div>
<div class="field">
<label for="channel-topic">Temat / opis</label>
<input id="channel-topic" name="topic" maxlength="255" autocomplete="off" placeholder="Opcjonalne">
</div>
<button class="btn btn-primary btn-large" type="submit">
Utwórz kanał i przenieś mnie</button>
</form>
{{end}}
{{end}}
</section>
</main>
</body>
</html>{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "stats.html"}}<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TeamSpeak — statystyki</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<a class="brand" href="/stats" aria-label="TeamSpeak — statystyki">
<span><strong>TeamSpeak</strong><small>Panel serwera</small></span>
</a>
<div class="header-right">
<div class="header-online" aria-live="polite">
<span class="online-dot" aria-hidden="true"></span>
<span>Online</span>
<strong id="online-count">{{.Current}}</strong>
</div>
<nav class="top-actions" aria-label="Nawigacja">
<a class="btn btn-ghost" href="{{.VoiceURL}}">Połącz</a>
<a class="btn btn-primary" href="/channel">Utwórz kanał</a>
</nav>
</div>
</header>
<main class="container">
<section class="page-intro">
<div>
<p class="eyebrow">Statystyki serwera</p>
<h1>Aktywność TeamSpeak 3</h1>
<p>Historia liczby użytkowników połączonych z serwerem.</p>
</div>
<span class="refresh-status" id="refresh-status">Odświeżanie automatyczne</span>
</section>
<section class="graphs" aria-label="Wykresy statystyk">
{{range .Ranges}}
<article class="graph-card">
<div class="graph-head">
<div>
<h2>{{.Title}}</h2>
<p>Liczba użytkowników online</p>
</div>
</div>
<button class="graph-image-button js-graph-open" type="button" data-full="/graph/{{.Key}}.png" aria-label="Powiększ wykres {{.Title}}">
<img class="js-live-graph" loading="lazy" src="/graph/{{.Key}}.png" data-src="/graph/{{.Key}}.png" alt="{{.Title}}">
</button>
</article>
{{end}}
</section>
</main>
</div>
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
<button class="lightbox-backdrop" type="button" data-lightbox-close aria-label="Zamknij podgląd"></button>
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="Powiększony wykres">
<button class="lightbox-close" type="button" data-lightbox-close>Zamknij</button>
<img id="lightbox-image" src="" alt="Powiększony wykres">
</div>
</div>
</body>
</html>{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "verify.html"}}<!doctype html>
<html lang="pl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Weryfikacja TS3</title><link rel="stylesheet" href="/assets/app.css"></head>
<body class="embed"><main class="panel"><nav class="panel-nav"><a href="/stats">← Statystyki</a><a href="{{.VoiceURL}}" target="_top">Połącz z TS3</a></nav><h1>Potwierdź klienta TS3</h1><p>Kod został wysłany prywatnie do <strong>{{.Client.Nick}}</strong>.</p><form method="post" action="/channel/verify/complete"><input type="hidden" name="form_token" value="{{.FormToken}}"><input type="hidden" name="challenge_token" value="{{.ChallengeToken}}"><label>Kod<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label><button class="button full" type="submit">Potwierdź</button></form></main></body></html>{{end}}
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"ts3monolith/internal/ts3"
)
type tokenManager struct{ secret []byte }
type tokenPayload struct {
Kind string `json:"k"`
IP string `json:"ip"`
Exp int64 `json:"exp"`
Nonce string `json:"n"`
ClID int `json:"clid,omitempty"`
DBID int `json:"dbid,omitempty"`
Nick string `json:"nick,omitempty"`
UID string `json:"uid,omitempty"`
}
func newTokenManager(secret string) *tokenManager { return &tokenManager{secret: []byte(secret)} }
func (m *tokenManager) sign(p tokenPayload) (string, error) {
raw, err := json.Marshal(p)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte(body))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return body + "." + sig, nil
}
func (m *tokenManager) verify(tok, kind, ip string) (tokenPayload, error) {
var p tokenPayload
body, sig, ok := splitToken(tok)
if !ok {
return p, errors.New("invalid token")
}
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte(body))
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
return p, errors.New("invalid token signature")
}
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return p, err
}
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")
}
return p, nil
}
func splitToken(s string) (string, string, bool) {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
return s[:i], s[i+1:], true
}
}
return "", "", false
}
func randomNonce() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (m *tokenManager) formToken(ip string) string {
t, _ := m.sign(tokenPayload{Kind: "form", IP: ip, Exp: time.Now().Add(10 * time.Minute).Unix(), Nonce: randomNonce()})
return t
}
func (m *tokenManager) clientToken(ip string, c ts3.Client) string {
t, _ := m.sign(tokenPayload{Kind: "client", IP: ip, Exp: time.Now().Add(10 * time.Minute).Unix(), Nonce: randomNonce(), ClID: c.ID, DBID: c.DatabaseID, Nick: c.Nick, UID: c.UID})
return t
}
func (m *tokenManager) challengeToken(ip string, c ts3.Client) (token string, code string, err error) {
p := tokenPayload{Kind: "challenge", IP: ip, Exp: time.Now().Add(5 * time.Minute).Unix(), Nonce: randomNonce(), ClID: c.ID, DBID: c.DatabaseID, Nick: c.Nick, UID: c.UID}
token, err = m.sign(p)
if err != nil {
return "", "", err
}
return token, m.challengeCode(p), nil
}
func (m *tokenManager) challengeCode(p tokenPayload) string {
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte("verify|" + p.Nonce + "|" + strconv.Itoa(p.ClID) + "|" + p.IP))
sum := mac.Sum(nil)
n := (uint32(sum[0])<<24 | uint32(sum[1])<<16 | uint32(sum[2])<<8 | uint32(sum[3])) % 1000000
return fmt.Sprintf("%06d", n)
}
+18
View File
@@ -0,0 +1,18 @@
package web
import (
"testing"
"ts3monolith/internal/ts3"
)
func TestTokens(t *testing.T) {
m := newTokenManager("0123456789012345678901234567890123456789")
tok := m.clientToken("1.2.3.4", ts3.Client{ID: 7, DatabaseID: 9, Nick: "Jan", UID: "u"})
p, err := m.verify(tok, "client", "1.2.3.4")
if err != nil || p.ClID != 7 || p.DBID != 9 {
t.Fatalf("verify failed: %#v %v", p, err)
}
if _, err := m.verify(tok, "client", "5.6.7.8"); err == nil {
t.Fatal("expected IP mismatch")
}
}