first commit
This commit is contained in:
commit
fccfe2a4c8
26 files changed
+1819
No files matched your search
@@ -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
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user