Files
nginx-stream-server/cmd/dashboard/main.go
jbergner 9faa53e149
All checks were successful
release-tag / release-image (push) Successful in 1m56s
bugfix-6
2025-09-21 19:46:29 +02:00

229 lines
5.9 KiB
Go

package main
import (
"context"
"fmt"
"html/template"
"log"
"mime"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/goccy/go-json"
"git.send.nrw/sendnrw/nginx-stream-server/internal/mtx"
)
var (
listen = env("LISTEN", ":8080")
mtxAPI = env("MTX_API", "http://127.0.0.1:9997")
mtxHLS = env("MTX_HLS", "http://127.0.0.1:8888")
streamsCSV = os.Getenv("STREAMS")
basicUser = os.Getenv("BASIC_AUTH_USER")
basicPass = os.Getenv("BASIC_AUTH_PASS")
// Root für Webassets (per ENV überschreibbar)
webRoot = env("WEB_ROOT", "web")
// Templates werden beim Start geladen
tpls *template.Template
)
func init() {
_ = mime.AddExtensionType(".css", "text/css")
_ = mime.AddExtensionType(".js", "application/javascript")
_ = mime.AddExtensionType(".mjs", "application/javascript")
_ = mime.AddExtensionType(".woff2", "font/woff2")
_ = mime.AddExtensionType(".woff", "font/woff")
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func mustLoadTemplates() *template.Template {
pattern := filepath.Join(webRoot, "templates", "*.html")
t, err := template.ParseGlob(pattern)
if err != nil {
log.Fatalf("templates laden fehlgeschlagen (%s): %v", pattern, err)
}
return t
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
tpls = mustLoadTemplates()
r := chi.NewRouter()
// Strenge CSP (alles lokal)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Security-Policy",
strings.Join([]string{
"default-src 'self'",
"img-src 'self' data:",
"style-src 'self'",
"font-src 'self'",
"script-src 'self'",
"connect-src 'self'",
"media-src 'self' blob:", // <— HIER blob: zulassen
"worker-src 'self' blob:",
}, "; "),
)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, req)
})
})
// API
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/api/streams", apiStreams)
})
// Optional Basic Auth für Seiten
if basicUser != "" {
creds := basicUser + ":" + basicPass
r.Group(func(p chi.Router) {
p.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(a) == 2 && a[0] == "Basic" && a[1] == basic(creds) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
http.Error(w, "auth required", http.StatusUnauthorized)
})
})
p.Get("/", pageIndex)
p.Get("/{name}", pageStream)
})
} else {
r.Get("/", pageIndex)
r.Get("/{name}", pageStream)
}
// /static → echtes Dateisystem
staticDir := http.Dir(filepath.Join(webRoot, "static"))
r.Handle("/static/*",
http.StripPrefix("/static",
http.FileServer(staticDir),
),
)
// HLS-Reverse-Proxy
up, _ := url.Parse(mtxHLS)
proxy := httputil.NewSingleHostReverseProxy(up)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
http.Error(w, "upstream error", http.StatusBadGateway)
}
r.Handle("/hls/*", http.StripPrefix("/hls", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "..") {
http.NotFound(w, r)
return
}
proxy.ServeHTTP(w, r)
})))
// Health
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
log.Printf("Dashboard listening on %s (API=%s HLS=%s, WEB_ROOT=%s)\n", listen, mtxAPI, mtxHLS, webRoot)
if err := http.ListenAndServe(listen, r); err != nil {
log.Fatal(err)
}
}
func apiStreams(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
c := &mtx.Client{BaseURL: mtxAPI, User: os.Getenv("MTX_API_USER"), Pass: os.Getenv("MTX_API_PASS")}
pl, err := c.Paths(ctx)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
return
}
allowed := map[string]bool{}
if streamsCSV != "" {
for _, s := range strings.Split(streamsCSV, ",") {
allowed[strings.TrimSpace(s)] = true
}
}
type item struct {
Name string `json:"name"`
Live bool `json:"live"`
Viewers int `json:"viewers"`
}
out := struct {
Items []item `json:"items"`
}{}
for _, p := range pl.Items {
if len(allowed) > 0 && !allowed[p.Name] {
continue
}
out.Items = append(out.Items, item{Name: p.Name, Live: p.Live(), Viewers: p.Viewers()})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
func pageIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpls.ExecuteTemplate(w, "index.html", nil); err != nil {
http.Error(w, err.Error(), 500)
}
}
func pageStream(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpls.ExecuteTemplate(w, "stream.html", map[string]any{
"Name": name,
"JSONName": fmt.Sprintf("%q", name),
}); err != nil {
http.Error(w, err.Error(), 500)
}
}
// Basic helper bleibt, falls du Basic Auth nutzt
func basic(creds string) string {
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
b := []byte(creds)
var out []byte
for i := 0; i < len(b); i += 3 {
var v uint32
n := 0
for j := 0; j < 3; j++ {
v <<= 8
if i+j < len(b) {
v |= uint32(b[i+j])
n++
}
}
for j := 0; j < 4; j++ {
if j <= n {
out = append(out, tbl[(v>>(18-6*uint(j)))&0x3f])
} else {
out = append(out, '=')
}
}
}
return string(out)
}