[ci skip] Add reverse proxy mode to f1-stream

Replace CPU-intensive headless Chrome + WebRTC pipeline with a
lightweight Go reverse proxy that strips anti-framing headers
(X-Frame-Options, CSP) and embeds streaming sites in iframes.

- New internal/proxy package with URL rewriting for HTML/CSS
- JS shim injection to intercept fetch/XHR/WebSocket/createElement
- Referer reconstruction for correct cross-origin auth (HLS streams)
- Inline iframe viewer preserving site navigation (not fullscreen overlay)
This commit is contained in:
Viktor Barzin 2026-02-21 21:23:21 +00:00
parent f7710b6067
commit 450dfc28e4
No known key found for this signature in database
GPG key ID: 0EB088298288D958
34 changed files with 6223 additions and 7 deletions

View file

@ -0,0 +1,3 @@
node_modules/
.claude/
.git/

View file

@ -1,4 +1,21 @@
FROM nginx
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /f1-stream .
COPY . /usr/share/nginx/html
EXPOSE 80
FROM alpine:3.20
RUN apk add --no-cache \
ca-certificates \
chromium nss freetype harfbuzz ttf-freefont \
mesa-dri-gallium mesa-gl \
dbus \
xvfb-run xorg-server \
pulseaudio pulseaudio-utils \
ffmpeg
ENV CHROME_PATH=/usr/bin/chromium-browser
COPY --from=builder /f1-stream /f1-stream
COPY static/ /static/
EXPOSE 8080
ENTRYPOINT ["/f1-stream"]

View file

@ -0,0 +1,45 @@
module f1-stream
go 1.24.1
require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-webauthn/webauthn v0.15.0
github.com/gobwas/ws v1.4.0
github.com/pion/webrtc/v4 v4.2.9
)
require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.1 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.10.0 // indirect
)

View file

@ -0,0 +1,89 @@
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY=
github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,359 @@
package auth
import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
"github.com/go-webauthn/webauthn/webauthn"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,30}$`)
type Auth struct {
store *store.Store
webauthn *webauthn.WebAuthn
adminUsername string
sessionTTL time.Duration
// In-memory storage for WebAuthn ceremony session data (short-lived)
regSessions map[string]*webauthn.SessionData
loginSessions map[string]*webauthn.SessionData
mu sync.Mutex
}
func New(s *store.Store, rpDisplayName, rpID string, rpOrigins []string, adminUsername string, sessionTTL time.Duration) (*Auth, error) {
wconfig := &webauthn.Config{
RPDisplayName: rpDisplayName,
RPID: rpID,
RPOrigins: rpOrigins,
}
w, err := webauthn.New(wconfig)
if err != nil {
return nil, fmt.Errorf("webauthn init: %w", err)
}
return &Auth{
store: s,
webauthn: w,
adminUsername: adminUsername,
sessionTTL: sessionTTL,
regSessions: make(map[string]*webauthn.SessionData),
loginSessions: make(map[string]*webauthn.SessionData),
}, nil
}
// BeginRegistration starts the WebAuthn registration ceremony.
func (a *Auth) BeginRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if !usernameRe.MatchString(req.Username) {
http.Error(w, `{"error":"username must be 3-30 chars, alphanumeric or underscore"}`, http.StatusBadRequest)
return
}
existing, err := a.store.GetUserByName(req.Username)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, `{"error":"username already taken"}`, http.StatusConflict)
return
}
id, err := randomID()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
isAdmin := false
if a.adminUsername != "" && req.Username == a.adminUsername {
isAdmin = true
} else if a.adminUsername == "" {
count, err := a.store.UserCount()
if err == nil && count == 0 {
isAdmin = true
}
}
user := &models.User{
ID: id,
Username: req.Username,
IsAdmin: isAdmin,
CreatedAt: time.Now(),
}
options, session, err := a.webauthn.BeginRegistration(user)
if err != nil {
log.Printf("BeginRegistration error: %v", err)
http.Error(w, `{"error":"failed to begin registration"}`, http.StatusInternalServerError)
return
}
a.mu.Lock()
a.regSessions[req.Username] = session
a.mu.Unlock()
// Clean up session after 5 minutes
go func() {
time.Sleep(5 * time.Minute)
a.mu.Lock()
delete(a.regSessions, req.Username)
a.mu.Unlock()
}()
// Store user temporarily - will be committed on finish
// We create the user now so FinishRegistration can look it up
if err := a.store.CreateUser(*user); err != nil {
http.Error(w, `{"error":"failed to create user"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
// FinishRegistration completes the WebAuthn registration ceremony.
func (a *Auth) FinishRegistration(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
// Username is passed as query param since body is the attestation response
username := r.URL.Query().Get("username")
if username == "" {
// Try to decode from a wrapper
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
return
}
req.Username = username
a.mu.Lock()
session, ok := a.regSessions[req.Username]
if ok {
delete(a.regSessions, req.Username)
}
a.mu.Unlock()
if !ok {
http.Error(w, `{"error":"no registration in progress"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(req.Username)
if err != nil || user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
return
}
credential, err := a.webauthn.FinishRegistration(user, *session, r)
if err != nil {
log.Printf("FinishRegistration error: %v", err)
http.Error(w, `{"error":"registration failed"}`, http.StatusBadRequest)
return
}
user.Credentials = append(user.Credentials, *credential)
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
http.Error(w, `{"error":"failed to save credential"}`, http.StatusInternalServerError)
return
}
// Create session
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
if err != nil {
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
MaxAge: int(a.sessionTTL.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// BeginLogin starts the WebAuthn login ceremony.
func (a *Auth) BeginLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(req.Username)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
if len(user.Credentials) == 0 {
http.Error(w, `{"error":"no credentials registered"}`, http.StatusBadRequest)
return
}
options, session, err := a.webauthn.BeginLogin(user)
if err != nil {
log.Printf("BeginLogin error: %v", err)
http.Error(w, `{"error":"failed to begin login"}`, http.StatusInternalServerError)
return
}
a.mu.Lock()
a.loginSessions[req.Username] = session
a.mu.Unlock()
go func() {
time.Sleep(5 * time.Minute)
a.mu.Lock()
delete(a.loginSessions, req.Username)
a.mu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(options)
}
// FinishLogin completes the WebAuthn login ceremony.
func (a *Auth) FinishLogin(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("username")
if username == "" {
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
return
}
a.mu.Lock()
session, ok := a.loginSessions[username]
if ok {
delete(a.loginSessions, username)
}
a.mu.Unlock()
if !ok {
http.Error(w, `{"error":"no login in progress"}`, http.StatusBadRequest)
return
}
user, err := a.store.GetUserByName(username)
if err != nil || user == nil {
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
return
}
credential, err := a.webauthn.FinishLogin(user, *session, r)
if err != nil {
log.Printf("FinishLogin error: %v", err)
http.Error(w, `{"error":"login failed"}`, http.StatusUnauthorized)
return
}
// Update credential sign count
for i, c := range user.Credentials {
if string(c.ID) == string(credential.ID) {
user.Credentials[i].Authenticator.SignCount = credential.Authenticator.SignCount
break
}
}
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
log.Printf("Failed to update credential sign count: %v", err)
}
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
if err != nil {
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil,
MaxAge: int(a.sessionTTL.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// Logout clears the session.
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
a.store.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
// Me returns the current user info.
func (a *Auth) Me(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"username": user.Username,
"is_admin": user.IsAdmin,
})
}
// GetSessionUser returns the user for a session token.
func (a *Auth) GetSessionUser(token string) (*models.User, error) {
sess, err := a.store.GetSession(token)
if err != nil || sess == nil {
return nil, err
}
return a.store.GetUserByID(sess.UserID)
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%x", b), nil
}

View file

@ -0,0 +1,20 @@
package auth
import (
"context"
"f1-stream/internal/models"
)
type contextKey string
const userKey contextKey = "user"
func ContextWithUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, userKey, user)
}
func UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(userKey).(*models.User)
return user
}

View file

@ -0,0 +1,38 @@
package extractor
import (
"log"
"os/exec"
)
const maxConcurrentSessions = 10
var sessionSem chan struct{}
// Init starts dbus, PulseAudio, and prepares the session semaphore.
func Init() {
// Start dbus (Chrome needs it for accessibility/service queries)
if err := exec.Command("mkdir", "-p", "/var/run/dbus").Run(); err == nil {
if err := exec.Command("dbus-daemon", "--system", "--nofork").Start(); err != nil {
log.Printf("extractor: warning: failed to start dbus: %v", err)
}
}
if err := exec.Command("pulseaudio", "--start", "--exit-idle-time=-1").Run(); err != nil {
log.Printf("extractor: warning: failed to start PulseAudio: %v", err)
}
// Create a null-sink as the default audio target for all sessions
exec.Command("pactl", "load-module", "module-null-sink",
"sink_name=virtual_sink",
"sink_properties=device.description=VirtualSink").Run()
exec.Command("pactl", "set-default-sink", "virtual_sink").Run()
sessionSem = make(chan struct{}, maxConcurrentSessions)
log.Println("extractor: initialized")
}
// Stop kills PulseAudio.
func Stop() {
exec.Command("pulseaudio", "--kill").Run()
log.Println("extractor: stopped")
}

View file

@ -0,0 +1,167 @@
package extractor
import (
"fmt"
"log"
"os"
"os/exec"
"sync/atomic"
"time"
)
var displayCounter int64 = 99
func nextDisplay() int {
return int(atomic.AddInt64(&displayCounter, 1))
}
// Capture manages an Xvfb display and separate ffmpeg pipelines for video and audio.
// Audio capture is best-effort — if PulseAudio is unavailable, video still works.
type Capture struct {
display int
xvfbCmd *exec.Cmd
videoCmd *exec.Cmd
audioCmd *exec.Cmd
videoR *os.File // IVF pipe reader (VP8 frames)
audioR *os.File // OGG pipe reader (Opus frames)
}
// NewCapture starts Xvfb on the given display and two ffmpeg processes:
// one for video (x11grab → VP8/IVF) and one for audio (pulse → Opus/OGG).
// Audio is best-effort — if it fails to start, video still works and audioR
// is set to a pipe that will return EOF immediately.
func NewCapture(display, width, height int) (*Capture, error) {
c := &Capture{display: display}
// Start Xvfb
screen := fmt.Sprintf("%dx%dx24", width, height)
c.xvfbCmd = exec.Command("Xvfb", fmt.Sprintf(":%d", display),
"-screen", "0", screen, "-ac", "-nolisten", "tcp")
if err := c.xvfbCmd.Start(); err != nil {
return nil, fmt.Errorf("capture: failed to start Xvfb: %w", err)
}
// Wait for Xvfb to be ready (X11 socket must exist)
ready := false
for i := 0; i < 50; i++ {
socketPath := fmt.Sprintf("/tmp/.X11-unix/X%d", display)
if _, err := os.Stat(socketPath); err == nil {
ready = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
c.xvfbCmd.Process.Kill()
c.xvfbCmd.Wait()
return nil, fmt.Errorf("capture: Xvfb did not start in time for display :%d", display)
}
// --- Video pipeline (required) ---
videoR, videoW, err := os.Pipe()
if err != nil {
c.cleanup()
return nil, fmt.Errorf("capture: video pipe: %w", err)
}
c.videoCmd = exec.Command("ffmpeg",
"-loglevel", "warning",
"-f", "x11grab", "-framerate", "30",
"-video_size", fmt.Sprintf("%dx%d", width, height),
"-i", fmt.Sprintf(":%d", display),
"-c:v", "libvpx",
"-quality", "realtime", "-cpu-used", "8",
"-deadline", "realtime", "-b:v", "2M", "-g", "30",
"-f", "ivf", "pipe:3",
)
c.videoCmd.ExtraFiles = []*os.File{videoW}
c.videoCmd.Stdout = os.Stderr
c.videoCmd.Stderr = os.Stderr
if err := c.videoCmd.Start(); err != nil {
videoR.Close()
videoW.Close()
c.cleanup()
return nil, fmt.Errorf("capture: failed to start video ffmpeg: %w", err)
}
videoW.Close()
c.videoR = videoR
go func() {
if err := c.videoCmd.Wait(); err != nil {
log.Printf("capture: video ffmpeg exited on display :%d: %v", display, err)
}
}()
// --- Audio pipeline (best-effort) ---
audioR, audioW, err := os.Pipe()
if err != nil {
log.Printf("capture: audio pipe failed on display :%d: %v (continuing without audio)", display, err)
// Provide a closed pipe so StreamAudio gets EOF immediately
r, w, _ := os.Pipe()
w.Close()
c.audioR = r
log.Printf("capture: started display :%d (%dx%d) (video only)", display, width, height)
return c, nil
}
c.audioCmd = exec.Command("ffmpeg",
"-loglevel", "warning",
"-f", "pulse", "-i", "virtual_sink.monitor",
"-c:a", "libopus",
"-b:a", "128k", "-application", "lowdelay",
"-f", "ogg", "pipe:3",
)
c.audioCmd.ExtraFiles = []*os.File{audioW}
c.audioCmd.Stdout = os.Stderr
c.audioCmd.Stderr = os.Stderr
if err := c.audioCmd.Start(); err != nil {
log.Printf("capture: audio ffmpeg failed to start on display :%d: %v (continuing without audio)", display, err)
audioR.Close()
audioW.Close()
// Provide a closed pipe so StreamAudio gets EOF immediately
r, w, _ := os.Pipe()
w.Close()
c.audioR = r
c.audioCmd = nil
log.Printf("capture: started display :%d (%dx%d) (video only)", display, width, height)
return c, nil
}
audioW.Close()
c.audioR = audioR
go func() {
if err := c.audioCmd.Wait(); err != nil {
log.Printf("capture: audio ffmpeg exited on display :%d: %v", display, err)
}
}()
log.Printf("capture: started display :%d (%dx%d) (video + audio)", display, width, height)
return c, nil
}
func (c *Capture) cleanup() {
if c.xvfbCmd != nil && c.xvfbCmd.Process != nil {
c.xvfbCmd.Process.Kill()
c.xvfbCmd.Wait()
}
}
// Close stops ffmpeg processes, Xvfb, and releases pipe resources.
func (c *Capture) Close() {
if c.videoCmd != nil && c.videoCmd.Process != nil {
c.videoCmd.Process.Kill()
}
if c.audioCmd != nil && c.audioCmd.Process != nil {
c.audioCmd.Process.Kill()
}
if c.videoR != nil {
c.videoR.Close()
}
if c.audioR != nil {
c.audioR.Close()
}
c.cleanup()
log.Printf("capture: stopped display :%d", c.display)
}

View file

@ -0,0 +1,383 @@
package extractor
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"github.com/pion/webrtc/v4"
)
const (
sessionTimeout = 5 * time.Minute
defaultViewportW = 1280
defaultViewportH = 720
turnCredentialTTL = 24 * time.Hour
)
var (
turnURL string
turnSharedSecret string
turnInternalURL string
)
// SetTURNConfig sets the TURN server URL, shared secret, and optional internal URL.
// The internal URL is used by pion (server-side) to avoid hairpin NAT issues.
// The public URL is sent to the browser client.
func SetTURNConfig(url, secret, internalURL string) {
turnURL = url
turnSharedSecret = secret
turnInternalURL = internalURL
if turnInternalURL == "" {
turnInternalURL = "turn:coturn.coturn.svc.cluster.local:3478"
}
log.Printf("extractor: TURN configured: public=%s internal=%s", url, turnInternalURL)
}
var adDomains = []string{
"doubleclick.net", "googlesyndication.com", "googleadservices.com",
"google-analytics.com", "adnxs.com", "criteo.com", "outbrain.com",
"taboola.com", "amazon-adsystem.com", "popads.net", "popcash.net",
"juicyads.com", "exoclick.com", "trafficjunky.com", "propellerads.com",
"adsterra.com", "hilltopads.net", "revcontent.com", "mgid.com",
}
type inputMsg struct {
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Button int `json:"button"`
DeltaX float64 `json:"deltaX"`
DeltaY float64 `json:"deltaY"`
Key string `json:"key"`
Code string `json:"code"`
Mods int `json:"modifiers"`
Width int `json:"width"`
Height int `json:"height"`
SDP string `json:"sdp"`
Candidate *webrtc.ICECandidateInit `json:"candidate"`
}
// HandleBrowserSession upgrades to WebSocket and runs a remote browser session
// with WebRTC video/audio streaming and CDP input relay.
func HandleBrowserSession(w http.ResponseWriter, r *http.Request, pageURL string) {
// Check session capacity
select {
case sessionSem <- struct{}{}:
defer func() { <-sessionSem }()
default:
http.Error(w, `{"error":"too many active browser sessions"}`, http.StatusServiceUnavailable)
return
}
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
log.Printf("extractor: session: ws upgrade failed: %v", err)
return
}
defer conn.Close()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Allocate display and start capture pipeline
display := nextDisplay()
viewW, viewH := defaultViewportW, defaultViewportH
cap, err := NewCapture(display, viewW, viewH)
if err != nil {
sendWSError(conn, "failed to start capture: "+err.Error())
log.Printf("extractor: session: capture error: %v", err)
return
}
defer cap.Close()
// Start Chrome on the virtual display
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-software-rasterizer", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-extensions", true),
chromedp.Flag("disable-background-networking", true),
chromedp.ModifyCmdFunc(func(cmd *exec.Cmd) {
cmd.Env = append(os.Environ(), fmt.Sprintf("DISPLAY=:%d", display))
}),
chromedp.Flag("autoplay-policy", "no-user-gesture-required"),
chromedp.Flag("window-size", fmt.Sprintf("%d,%d", viewW, viewH)),
chromedp.WSURLReadTimeout(30 * time.Second),
)
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
defer tabCancel()
var wsMu sync.Mutex
// Build ICE servers for pion (server-side) — uses internal TURN URL to avoid hairpin NAT
iceServers := []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
}
var turnCreds *TURNCredentials
if turnURL != "" && turnSharedSecret != "" {
// Server-side: use internal k8s DNS for TURN to bypass NAT
internalCreds := GenerateTURNCredentials(turnInternalURL, turnSharedSecret, turnCredentialTTL)
turnCreds = &internalCreds
iceServers = append(iceServers, webrtc.ICEServer{
URLs: internalCreds.URLs,
Username: internalCreds.Username,
Credential: internalCreds.Credential,
CredentialType: webrtc.ICECredentialTypePassword,
})
}
// Build ad-blocking fetch patterns
adPatterns := make([]*fetch.RequestPattern, 0, len(adDomains))
for _, domain := range adDomains {
adPatterns = append(adPatterns, &fetch.RequestPattern{
URLPattern: fmt.Sprintf("*://*.%s/*", domain),
})
}
// Set up event listeners before navigation
chromedp.ListenTarget(tabCtx, func(ev interface{}) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
go chromedp.Run(tabCtx, fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient))
case *page.EventFrameNavigated:
if e.Frame.ParentID == "" {
go sendURLUpdate(tabCtx, conn, &wsMu, e.Frame.URL)
}
case *page.EventNavigatedWithinDocument:
go sendURLUpdate(tabCtx, conn, &wsMu, e.URL)
}
})
// Enable fetch interception (ad blocking) and navigate
if err := chromedp.Run(tabCtx,
fetch.Enable().WithPatterns(adPatterns),
chromedp.Navigate(pageURL),
chromedp.WaitReady("body"),
); err != nil {
sendWSError(conn, "navigation failed")
log.Printf("extractor: session: navigate error for %s: %v", pageURL, err)
return
}
// Create WebRTC media stream
mediaStream, err := NewMediaStream(iceServers, func(c *webrtc.ICECandidate) {
data, _ := json.Marshal(map[string]interface{}{
"type": "ice",
"candidate": c.ToJSON(),
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, data)
wsMu.Unlock()
}, cancel)
if err != nil {
sendWSError(conn, "WebRTC setup failed")
log.Printf("extractor: session: webrtc error: %v", err)
return
}
defer mediaStream.Close()
// Create and send SDP offer
sdp, err := mediaStream.Offer()
if err != nil {
sendWSError(conn, "WebRTC offer failed")
log.Printf("extractor: session: offer error: %v", err)
return
}
// Send ICE config to client — uses PUBLIC TURN URL (for browser to reach from internet)
clientICE := []map[string]interface{}{
{"urls": []string{"stun:stun.l.google.com:19302"}},
}
if turnCreds != nil {
// Client-side: use public IP for TURN (browser connects from internet)
publicCreds := GenerateTURNCredentials(turnURL, turnSharedSecret, turnCredentialTTL)
clientICE = append(clientICE, map[string]interface{}{
"urls": publicCreds.URLs,
"username": publicCreds.Username,
"credential": publicCreds.Credential,
})
}
iceMsg, _ := json.Marshal(map[string]interface{}{
"type": "iceServers",
"iceServers": clientICE,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, iceMsg)
wsMu.Unlock()
offerMsg, _ := json.Marshal(map[string]interface{}{
"type": "offer",
"sdp": sdp,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, offerMsg)
wsMu.Unlock()
// Send ready message with viewport dimensions
readyMsg, _ := json.Marshal(map[string]interface{}{
"type": "ready",
"width": viewW,
"height": viewH,
})
wsMu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, readyMsg)
wsMu.Unlock()
// Start streaming video and audio from capture pipes
go mediaStream.StreamVideo(cap.videoR, ctx)
go mediaStream.StreamAudio(cap.audioR, ctx)
log.Printf("extractor: session: started for %s (display :%d)", pageURL, display)
// Inactivity timer — cancels session after no client input
inactivity := time.NewTimer(sessionTimeout)
defer inactivity.Stop()
go func() {
select {
case <-inactivity.C:
log.Printf("extractor: session: inactivity timeout for %s", pageURL)
cancel()
case <-ctx.Done():
}
}()
// Read loop — process signaling and input messages
for {
msgs, err := wsutil.ReadClientMessage(conn, nil)
if err != nil {
break
}
for _, m := range msgs {
if m.OpCode != ws.OpText {
continue
}
// Reset inactivity timer
if !inactivity.Stop() {
select {
case <-inactivity.C:
default:
}
}
inactivity.Reset(sessionTimeout)
var msg inputMsg
if err := json.Unmarshal(m.Payload, &msg); err != nil {
continue
}
switch msg.Type {
case "answer":
if err := mediaStream.SetAnswer(msg.SDP); err != nil {
log.Printf("extractor: session: set answer error: %v", err)
}
case "ice":
if msg.Candidate != nil {
if err := mediaStream.AddICECandidate(*msg.Candidate); err != nil {
log.Printf("extractor: session: add ICE error: %v", err)
}
}
case "back":
chromedp.Run(tabCtx, chromedp.NavigateBack())
case "forward":
chromedp.Run(tabCtx, chromedp.NavigateForward())
default:
handleInput(tabCtx, &msg)
}
}
}
log.Printf("extractor: session: ended for %s", pageURL)
}
func handleInput(ctx context.Context, msg *inputMsg) {
switch msg.Type {
case "mousemove":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseMoved, msg.X, msg.Y))
case "mousedown":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MousePressed, msg.X, msg.Y).
WithButton(mapButton(msg.Button)).WithClickCount(1))
case "mouseup":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseReleased, msg.X, msg.Y).
WithButton(mapButton(msg.Button)))
case "scroll":
chromedp.Run(ctx,
input.DispatchMouseEvent(input.MouseWheel, msg.X, msg.Y).
WithDeltaX(msg.DeltaX).WithDeltaY(msg.DeltaY))
case "keydown":
chromedp.Run(ctx,
input.DispatchKeyEvent(input.KeyDown).
WithKey(msg.Key).WithCode(msg.Code).
WithModifiers(input.Modifier(msg.Mods)))
case "keyup":
chromedp.Run(ctx,
input.DispatchKeyEvent(input.KeyUp).
WithKey(msg.Key).WithCode(msg.Code).
WithModifiers(input.Modifier(msg.Mods)))
}
}
func mapButton(jsButton int) input.MouseButton {
switch jsButton {
case 1:
return input.Middle
case 2:
return input.Right
default:
return input.Left
}
}
func sendURLUpdate(tabCtx context.Context, conn net.Conn, mu *sync.Mutex, currentURL string) {
var canBack, canForward bool
var entries []*page.NavigationEntry
var currentIndex int64
if err := chromedp.Run(tabCtx, chromedp.ActionFunc(func(ctx context.Context) error {
var err error
currentIndex, entries, err = page.GetNavigationHistory().Do(ctx)
return err
})); err == nil {
canBack = currentIndex > 0
canForward = int(currentIndex) < len(entries)-1
}
data, _ := json.Marshal(map[string]interface{}{
"type": "url",
"url": currentURL,
"canBack": canBack,
"canForward": canForward,
})
mu.Lock()
wsutil.WriteServerMessage(conn, ws.OpText, data)
mu.Unlock()
}
func sendWSError(conn net.Conn, msg string) {
data, _ := json.Marshal(map[string]string{"type": "error", "message": msg})
wsutil.WriteServerMessage(conn, ws.OpText, data)
}

View file

@ -0,0 +1,248 @@
package extractor
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"log"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
"github.com/pion/webrtc/v4/pkg/media/oggreader"
)
// TURNCredentials holds ephemeral TURN credentials generated from a shared secret.
type TURNCredentials struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
// GenerateTURNCredentials creates time-limited TURN credentials using the
// shared secret (TURN REST API / coturn --use-auth-secret).
func GenerateTURNCredentials(turnURL, sharedSecret string, ttl time.Duration) TURNCredentials {
expiry := time.Now().Add(ttl).Unix()
username := fmt.Sprintf("%d", expiry)
mac := hmac.New(sha1.New, []byte(sharedSecret))
mac.Write([]byte(username))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return TURNCredentials{
URLs: []string{turnURL},
Username: username,
Credential: credential,
}
}
// MediaStream wraps a pion WebRTC PeerConnection with VP8 video and Opus audio tracks.
type MediaStream struct {
pc *webrtc.PeerConnection
videoTrack *webrtc.TrackLocalStaticSample
audioTrack *webrtc.TrackLocalStaticSample
}
// NewMediaStream creates a PeerConnection with VP8 + Opus tracks and an ICE callback.
// The cancel function is called when ICE fails to trigger session cleanup.
func NewMediaStream(iceServers []webrtc.ICEServer, onICE func(*webrtc.ICECandidate), cancel context.CancelFunc) (*MediaStream, error) {
config := webrtc.Configuration{
ICEServers: iceServers,
}
pc, err := webrtc.NewPeerConnection(config)
if err != nil {
return nil, err
}
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
"video", "stream",
)
if err != nil {
pc.Close()
return nil, err
}
audioTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio", "stream",
)
if err != nil {
pc.Close()
return nil, err
}
if _, err = pc.AddTrack(videoTrack); err != nil {
pc.Close()
return nil, err
}
if _, err = pc.AddTrack(audioTrack); err != nil {
pc.Close()
return nil, err
}
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Printf("webrtc: ICE connection state: %s", state.String())
if state == webrtc.ICEConnectionStateFailed {
log.Printf("webrtc: ICE failed, cancelling session")
cancel()
return
}
if state == webrtc.ICEConnectionStateConnected {
// Log selected candidate pair
if stats := pc.GetStats(); stats != nil {
for _, s := range stats {
if cp, ok := s.(webrtc.ICECandidatePairStats); ok && cp.Nominated {
log.Printf("webrtc: selected candidate pair: local=%s remote=%s",
cp.LocalCandidateID, cp.RemoteCandidateID)
}
}
}
// Start periodic stats logging
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if pc.ICEConnectionState() != webrtc.ICEConnectionStateConnected &&
pc.ICEConnectionState() != webrtc.ICEConnectionStateCompleted {
return
}
stats := pc.GetStats()
for _, s := range stats {
if out, ok := s.(webrtc.OutboundRTPStreamStats); ok {
log.Printf("webrtc: outbound-rtp kind=%s bytes=%d packets=%d",
out.Kind, out.BytesSent, out.PacketsSent)
}
}
}
}()
}
})
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
log.Printf("webrtc: peer connection state: %s", state.String())
})
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c != nil {
log.Printf("webrtc: gathered ICE candidate: type=%s addr=%s:%d",
c.Typ.String(), c.Address, c.Port)
if onICE != nil {
onICE(c)
}
}
})
return &MediaStream{
pc: pc,
videoTrack: videoTrack,
audioTrack: audioTrack,
}, nil
}
// Offer creates an SDP offer, sets it as local description, and returns the SDP string.
func (m *MediaStream) Offer() (string, error) {
offer, err := m.pc.CreateOffer(nil)
if err != nil {
return "", err
}
if err := m.pc.SetLocalDescription(offer); err != nil {
return "", err
}
return offer.SDP, nil
}
// SetAnswer sets the remote SDP answer.
func (m *MediaStream) SetAnswer(sdp string) error {
return m.pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: sdp,
})
}
// AddICECandidate adds a remote ICE candidate.
func (m *MediaStream) AddICECandidate(init webrtc.ICECandidateInit) error {
return m.pc.AddICECandidate(init)
}
// StreamVideo reads VP8 frames from an IVF stream and writes them to the video track.
// Blocks until the reader returns an error or the context is cancelled.
func (m *MediaStream) StreamVideo(r io.Reader, ctx context.Context) {
ivf, _, err := ivfreader.NewWith(r)
if err != nil {
log.Printf("webrtc: ivf reader error: %v", err)
return
}
duration := time.Second / 30
for {
select {
case <-ctx.Done():
return
default:
}
frame, _, err := ivf.ParseNextFrame()
if err != nil {
if err != io.EOF {
log.Printf("webrtc: video frame error: %v", err)
}
return
}
if err := m.videoTrack.WriteSample(media.Sample{
Data: frame,
Duration: duration,
}); err != nil {
log.Printf("webrtc: video write error: %v", err)
return
}
}
}
// StreamAudio reads Opus pages from an OGG stream and writes them to the audio track.
// Blocks until the reader returns an error or the context is cancelled.
func (m *MediaStream) StreamAudio(r io.Reader, ctx context.Context) {
ogg, _, err := oggreader.NewWith(r)
if err != nil {
log.Printf("webrtc: ogg reader error: %v", err)
return
}
for {
select {
case <-ctx.Done():
return
default:
}
page, _, err := ogg.ParseNextPage()
if err != nil {
if err != io.EOF {
log.Printf("webrtc: audio page error: %v", err)
}
return
}
if err := m.audioTrack.WriteSample(media.Sample{
Data: page,
Duration: 20 * time.Millisecond,
}); err != nil {
log.Printf("webrtc: audio write error: %v", err)
return
}
}
}
// Close closes the underlying PeerConnection.
func (m *MediaStream) Close() {
if m.pc != nil {
m.pc.Close()
}
}

View file

@ -0,0 +1,188 @@
package healthcheck
import (
"context"
"log"
"net/http"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
)
const unhealthyThreshold = 5
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
// isReachable sends a GET request and returns true if the server responds with
// an HTTP 2xx or 3xx status code.
func isReachable(client *http.Client, rawURL string) bool {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 400
}
type HealthChecker struct {
store *store.Store
interval time.Duration
timeout time.Duration
client *http.Client
mu sync.Mutex
}
func New(s *store.Store, interval, timeout time.Duration) *HealthChecker {
return &HealthChecker{
store: s,
interval: interval,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
},
}
}
func (hc *HealthChecker) Run(ctx context.Context) {
log.Printf("healthcheck: starting with interval=%v timeout=%v", hc.interval, hc.timeout)
hc.checkAll()
ticker := time.NewTicker(hc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("healthcheck: shutting down")
return
case <-ticker.C:
hc.checkAll()
}
}
}
func (hc *HealthChecker) checkAll() {
hc.mu.Lock()
defer hc.mu.Unlock()
start := time.Now()
urls := hc.collectURLs()
log.Printf("healthcheck: checking %d URLs", len(urls))
existing, err := hc.store.LoadHealthStates()
if err != nil {
log.Printf("healthcheck: failed to load health states: %v", err)
existing = nil
}
stateMap := make(map[string]*models.HealthState, len(existing))
for i := range existing {
stateMap[existing[i].URL] = &existing[i]
}
now := time.Now()
var recovered, newlyUnhealthy int
for _, url := range urls {
st, exists := stateMap[url]
if !exists {
st = &models.HealthState{
URL: url,
Healthy: true,
}
stateMap[url] = st
}
ok := isReachable(hc.client, url)
if ok {
if !st.Healthy {
log.Printf("healthcheck: recovered %s", truncate(url, 80))
recovered++
}
st.ConsecutiveFailures = 0
st.Healthy = true
} else {
st.ConsecutiveFailures++
if st.ConsecutiveFailures >= unhealthyThreshold && st.Healthy {
st.Healthy = false
log.Printf("healthcheck: marking unhealthy after %d failures: %s", st.ConsecutiveFailures, truncate(url, 80))
newlyUnhealthy++
}
}
st.LastCheckTime = now
}
// Prune orphaned entries: only keep states whose URL is in the current set
urlSet := make(map[string]bool, len(urls))
for _, u := range urls {
urlSet[u] = true
}
var finalStates []models.HealthState
healthyCount := 0
for _, st := range stateMap {
if urlSet[st.URL] {
finalStates = append(finalStates, *st)
if st.Healthy {
healthyCount++
}
}
}
if err := hc.store.SaveHealthStates(finalStates); err != nil {
log.Printf("healthcheck: failed to save health states: %v", err)
}
log.Printf("healthcheck: done in %v, checked=%d healthy=%d recovered=%d newly_unhealthy=%d",
time.Since(start).Round(time.Millisecond), len(urls), healthyCount, recovered, newlyUnhealthy)
}
func (hc *HealthChecker) collectURLs() []string {
seen := make(map[string]bool)
streams, err := hc.store.LoadStreams()
if err != nil {
log.Printf("healthcheck: failed to load streams: %v", err)
} else {
for _, s := range streams {
seen[s.URL] = true
}
}
scraped, err := hc.store.LoadScrapedLinks()
if err != nil {
log.Printf("healthcheck: failed to load scraped links: %v", err)
} else {
for _, l := range scraped {
seen[l.URL] = true
}
}
urls := make([]string, 0, len(seen))
for u := range seen {
urls = append(urls, u)
}
return urls
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -0,0 +1,53 @@
package models
import (
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
Credentials []webauthn.Credential `json:"credentials"`
CreatedAt time.Time `json:"created_at"`
}
// WebAuthn interface implementation
func (u *User) WebAuthnID() []byte { return []byte(u.ID) }
func (u *User) WebAuthnName() string { return u.Username }
func (u *User) WebAuthnDisplayName() string { return u.Username }
func (u *User) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
type Stream struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
SubmittedBy string `json:"submitted_by"`
Published bool `json:"published"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
}
type ScrapedLink struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Source string `json:"source"`
ScrapedAt time.Time `json:"scraped_at"`
Stale bool `json:"stale"`
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type HealthState struct {
URL string `json:"url"`
ConsecutiveFailures int `json:"consecutive_failures"`
LastCheckTime time.Time `json:"last_check_time"`
Healthy bool `json:"healthy"`
}

View file

@ -0,0 +1,429 @@
package proxy
import (
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
)
// hopHeaders are headers that should not be forwarded by proxies.
var hopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Te": true,
"Trailers": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// antiFrameHeaders are headers we strip to allow iframe embedding.
var antiFrameHeaders = []string{
"X-Frame-Options",
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"X-Content-Type-Options",
}
// forwardHeaders are request headers we copy from the client to the upstream.
// NOTE: Accept-Encoding is intentionally omitted so Go's Transport handles
// compression transparently (adds gzip, auto-decompresses response body).
// This ensures we can do text replacements on HTML/CSS bodies.
var forwardHeaders = []string{
"User-Agent",
"Accept",
"Accept-Language",
"Cookie",
"Referer",
"Range",
"If-None-Match",
"If-Modified-Since",
"Cache-Control",
}
// jsShimTemplate is injected into HTML responses to intercept JS-initiated requests.
// It patches fetch, XMLHttpRequest, WebSocket, and EventSource to route through the proxy.
const jsShimTemplate = `<script data-proxy-shim="1">(function(){
var P='/proxy/%s';
var O='%s';
var H=location.origin;
function b64(s){return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/g,'');}
function rw(u){
if(!u||typeof u!=='string')return u;
if(u.startsWith('/proxy/'))return u;
if(u.startsWith(H+'/proxy/'))return u.slice(H.length);
if(u.startsWith(H+'/'))return u.slice(H.length);
if(u.startsWith(H))return u.slice(H.length)||'/';
if(u.startsWith('/'))return P+u;
if(u.startsWith(O))return P+u.slice(O.length);
try{var p=new URL(u);if(p.protocol==='http:'||p.protocol==='https:'){return'/proxy/'+b64(p.origin)+p.pathname+p.search+p.hash;}}catch(e){}
return u;
}
var _f=window.fetch;
window.fetch=function(i,o){
if(typeof i==='string')i=rw(i);
else if(i&&i.url)i=new Request(rw(i.url),i);
return _f.call(this,i,o);
};
var _xo=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(m,u){var a=[].slice.call(arguments);a[1]=rw(u);return _xo.apply(this,a);};
var _ws=window.WebSocket;
window.WebSocket=function(u,p){return new _ws(rw(u),p);};
window.WebSocket.prototype=_ws.prototype;
window.WebSocket.CONNECTING=_ws.CONNECTING;
window.WebSocket.OPEN=_ws.OPEN;
window.WebSocket.CLOSING=_ws.CLOSING;
window.WebSocket.CLOSED=_ws.CLOSED;
if(window.EventSource){var _es=window.EventSource;window.EventSource=function(u,o){return new _es(rw(u),o);};window.EventSource.prototype=_es.prototype;}
var _ce=document.createElement.bind(document);
document.createElement=function(t){
var el=_ce(t);
var tag=t.toLowerCase();
if(tag==='script'||tag==='iframe'||tag==='img'||tag==='link'||tag==='source'||tag==='video'||tag==='audio'){
var _ss=Object.getOwnPropertyDescriptor(HTMLElement.prototype,'src')||Object.getOwnPropertyDescriptor(el.__proto__,'src');
if(_ss&&_ss.set){Object.defineProperty(el,'src',{get:function(){return _ss.get?_ss.get.call(this):'';},set:function(v){_ss.set.call(this,rw(v));},configurable:true});}
}
return el;
};
})();</script>`
// NewHandler returns an http.Handler that serves the reverse proxy at /proxy/.
// URL structure: /proxy/{base64_origin}/{path...}
func NewHandler() http.Handler {
client := &http.Client{
Timeout: 30 * 1000000000, // 30s
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // don't follow redirects
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse: /proxy/{base64_origin}/{path...}
trimmed := strings.TrimPrefix(r.URL.Path, "/proxy/")
if trimmed == "" || trimmed == r.URL.Path {
http.Error(w, "bad proxy URL", http.StatusBadRequest)
return
}
// Split into base64 segment and remaining path
slashIdx := strings.Index(trimmed, "/")
var b64Origin, pathAndQuery string
if slashIdx == -1 {
b64Origin = trimmed
pathAndQuery = "/"
} else {
b64Origin = trimmed[:slashIdx]
pathAndQuery = trimmed[slashIdx:]
}
originBytes, err := base64.RawURLEncoding.DecodeString(b64Origin)
if err != nil {
// Try standard encoding with padding
originBytes, err = base64.StdEncoding.DecodeString(b64Origin)
if err != nil {
http.Error(w, "invalid base64 origin", http.StatusBadRequest)
return
}
}
origin := string(originBytes)
// Validate origin is a valid URL
originURL, err := url.Parse(origin)
if err != nil || (originURL.Scheme != "http" && originURL.Scheme != "https") {
http.Error(w, "invalid origin URL", http.StatusBadRequest)
return
}
// Build upstream URL
targetURL := origin + pathAndQuery
if r.URL.RawQuery != "" {
targetURL += "?" + r.URL.RawQuery
}
log.Printf("proxy: %s %s -> %s", r.Method, r.URL.Path, targetURL)
// Create upstream request
upReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
// Copy selected headers
for _, h := range forwardHeaders {
if v := r.Header.Get(h); v != "" {
upReq.Header.Set(h, v)
}
}
// Reconstruct the original Referer from the client's proxy-rewritten Referer.
// The client sends e.g. "https://f1.viktorbarzin.me/proxy/{b64origin}/path"
// and we need to decode that back to "https://original.com/path".
upReq.Header.Set("Referer", decodeProxyReferer(r.Header.Get("Referer"), origin))
// Fetch upstream
resp, err := client.Do(upReq)
if err != nil {
log.Printf("proxy: upstream fetch failed: %v", err)
http.Error(w, "upstream fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
log.Printf("proxy: %s %s <- %d (%s)", r.Method, r.URL.Path, resp.StatusCode, resp.Header.Get("Content-Type"))
// Handle redirects: rewrite Location header through proxy
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc != "" {
rewritten := rewriteRedirect(loc, origin, b64Origin)
w.Header().Set("Location", rewritten)
log.Printf("proxy: redirect %s -> %s", loc, rewritten)
}
w.WriteHeader(resp.StatusCode)
return
}
// Copy response headers, stripping anti-frame, hop-by-hop, and encoding headers.
// Content-Encoding is stripped because Go's Transport already decompressed the body.
// Content-Length is stripped because we may rewrite the body (changing its length).
for key, vals := range resp.Header {
if hopHeaders[key] {
continue
}
if strings.EqualFold(key, "Content-Encoding") || strings.EqualFold(key, "Content-Length") {
continue
}
skip := false
for _, ah := range antiFrameHeaders {
if strings.EqualFold(key, ah) {
skip = true
break
}
}
if skip {
continue
}
for _, v := range vals {
w.Header().Add(key, v)
}
}
// Add permissive CORS headers so cross-origin XHR/fetch from the iframe works
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.WriteHeader(resp.StatusCode)
// For HTML responses, rewrite URLs and inject JS shim
ct := resp.Header.Get("Content-Type")
if strings.Contains(ct, "text/html") {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("proxy: failed to read HTML body: %v", err)
return
}
rewritten := rewriteHTML(string(body), origin, b64Origin)
w.Write([]byte(rewritten))
return
}
// For CSS responses, rewrite url() references
if strings.Contains(ct, "text/css") {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("proxy: failed to read CSS body: %v", err)
return
}
rewritten := rewriteCSS(string(body), origin, b64Origin)
w.Write([]byte(rewritten))
return
}
// Stream other responses directly
io.Copy(w, resp.Body)
})
}
// rewriteRedirect rewrites a Location header value to route through the proxy.
func rewriteRedirect(loc, origin, b64Origin string) string {
// Absolute URL on the same origin
if strings.HasPrefix(loc, origin) {
path := strings.TrimPrefix(loc, origin)
return "/proxy/" + b64Origin + path
}
// Absolute URL on a different origin — proxy it too
parsed, err := url.Parse(loc)
if err != nil {
return loc
}
if parsed.IsAbs() {
newOrigin := parsed.Scheme + "://" + parsed.Host
newB64 := base64.RawURLEncoding.EncodeToString([]byte(newOrigin))
return "/proxy/" + newB64 + parsed.RequestURI()
}
// Relative URL — it will resolve naturally
return loc
}
// Precompiled regexes for root-relative URL rewriting in HTML attributes.
// Matches src="/...", href="/...", action="/...", poster="/..." but NOT "//..." (protocol-relative).
var rootRelativeAttrRe = regexp.MustCompile(`((?:src|href|action|poster|data)\s*=\s*["'])/([^/"'][^"']*)`)
// Matches url("/...") or url('/...') or url(/...) in inline styles — but NOT url("//...")
var rootRelativeCSSRe = regexp.MustCompile(`(url\(\s*["']?)/([^/"')[^"')]*)(["']?\s*\))`)
// crossOriginIframeSrcRe matches <iframe src="https://..."> to proxy cross-origin embeds.
var crossOriginIframeSrcRe = regexp.MustCompile(`(<iframe[^>]*\ssrc\s*=\s*["'])(https?://[^"']+)(["'])`)
// rewriteHTML replaces URLs and injects the JS shim to intercept runtime requests.
func rewriteHTML(body, origin, b64Origin string) string {
proxyPrefix := "/proxy/" + b64Origin
// 1. Rewrite absolute URLs matching the target origin
escaped := regexp.QuoteMeta(origin)
absRe := regexp.MustCompile(escaped + `(/[^"'\s>)]*)?`)
body = absRe.ReplaceAllStringFunc(body, func(match string) string {
path := strings.TrimPrefix(match, origin)
if path == "" {
path = "/"
}
return proxyPrefix + path
})
// 2. Rewrite root-relative URLs in HTML attributes (src="/...", href="/...", etc.)
// Skip URLs already rewritten by step 1 (starting with /proxy/)
body = rootRelativeAttrRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeAttrRe.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
// m[2] is the path after the leading "/", skip if already proxied
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2]
})
// 3. Rewrite root-relative URLs in inline CSS url() references
// Skip URLs already rewritten by step 1 (starting with /proxy/)
body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeCSSRe.FindStringSubmatch(match)
if len(m) < 4 {
return match
}
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2] + m[3]
})
// 4. Rewrite cross-origin iframe src attributes to route through proxy
body = crossOriginIframeSrcRe.ReplaceAllStringFunc(body, func(match string) string {
m := crossOriginIframeSrcRe.FindStringSubmatch(match)
if len(m) < 4 {
return match
}
prefix, iframeURL, quote := m[1], m[2], m[3]
parsed, err := url.Parse(iframeURL)
if err != nil {
return match
}
iframeOrigin := parsed.Scheme + "://" + parsed.Host
iframeB64 := base64.RawURLEncoding.EncodeToString([]byte(iframeOrigin))
return prefix + "/proxy/" + iframeB64 + parsed.RequestURI() + quote
})
// 5. Inject JS shim right after <head> to intercept fetch/XHR/WebSocket
shim := fmt.Sprintf(jsShimTemplate, b64Origin, origin)
headIdx := strings.Index(strings.ToLower(body), "<head>")
if headIdx != -1 {
insertPos := headIdx + len("<head>")
body = body[:insertPos] + shim + body[insertPos:]
} else {
// No <head> tag — prepend to body
body = shim + body
}
return body
}
// rewriteCSS replaces root-relative url() references in CSS to route through the proxy.
func rewriteCSS(body, origin, b64Origin string) string {
proxyPrefix := "/proxy/" + b64Origin
// Rewrite absolute URLs matching origin
escaped := regexp.QuoteMeta(origin)
absRe := regexp.MustCompile(escaped + `(/[^"'\s)]*)?`)
body = absRe.ReplaceAllStringFunc(body, func(match string) string {
path := strings.TrimPrefix(match, origin)
if path == "" {
path = "/"
}
return proxyPrefix + path
})
// Rewrite root-relative url() references, skip already-proxied
body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string {
m := rootRelativeCSSRe.FindStringSubmatch(match)
if len(m) < 4 {
return match
}
if strings.HasPrefix(m[2], "proxy/") {
return match
}
return m[1] + proxyPrefix + "/" + m[2] + m[3]
})
return body
}
// decodeProxyReferer takes the client's Referer (which points to a proxy URL)
// and decodes it back to the original upstream URL. This is critical for
// cross-origin requests where the upstream checks the Referer (e.g. HLS servers).
// Falls back to origin+"/" if decoding fails.
func decodeProxyReferer(clientReferer, fallbackOrigin string) string {
if clientReferer == "" {
return fallbackOrigin + "/"
}
// Find /proxy/ in the Referer URL path
idx := strings.Index(clientReferer, "/proxy/")
if idx == -1 {
return fallbackOrigin + "/"
}
// Extract everything after /proxy/
rest := clientReferer[idx+len("/proxy/"):]
if rest == "" {
return fallbackOrigin + "/"
}
// Split into base64 segment and remaining path
slashIdx := strings.Index(rest, "/")
var b64Seg, pathPart string
if slashIdx == -1 {
b64Seg = rest
pathPart = "/"
} else {
b64Seg = rest[:slashIdx]
pathPart = rest[slashIdx:]
}
// Decode the base64 origin
originBytes, err := base64.RawURLEncoding.DecodeString(b64Seg)
if err != nil {
originBytes, err = base64.StdEncoding.DecodeString(b64Seg)
if err != nil {
return fallbackOrigin + "/"
}
}
return string(originBytes) + pathPart
}

View file

@ -0,0 +1,327 @@
package scraper
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"f1-stream/internal/models"
)
const (
subredditURL = "https://www.reddit.com/r/motorsportsstreams2/new.json?limit=25"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
requestDelay = 1 * time.Second
)
var (
urlRe = regexp.MustCompile(`https?://[^\s\)\]\>"]+`)
// Keywords in post title that indicate F1 content (matched case-insensitively)
f1Keywords = []string{
"f1",
"formula 1",
"formula one",
"formula1",
"grand prix",
"gp qualifying",
"gp race",
"gp sprint",
"gp practice",
}
f1NegativeKeywords = []string{
"f1 key",
"function 1",
"help f1",
}
// URLs to filter out (not stream sources)
filteredDomains = map[string]bool{
"reddit.com": true,
"www.reddit.com": true,
"imgur.com": true,
"i.imgur.com": true,
"redd.it": true,
"i.redd.it": true,
"v.redd.it": true,
"youtu.be": true,
"youtube.com": true,
"twitter.com": true,
"x.com": true,
}
)
type redditListing struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
SelfText string `json:"selftext"`
Permalink string `json:"permalink"`
CreatedUTC float64 `json:"created_utc"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
type redditComments []struct {
Data struct {
Children []struct {
Data struct {
Body string `json:"body"`
Replies json.RawMessage `json:"replies"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func scrapeReddit() ([]models.ScrapedLink, error) {
client := &http.Client{Timeout: 15 * time.Second}
var allLinks []models.ScrapedLink
seen := make(map[string]bool)
log.Printf("scraper: fetching listing from %s", subredditURL)
listing, err := fetchJSON[redditListing](client, subredditURL)
if err != nil {
return nil, fmt.Errorf("fetch listing: %w", err)
}
totalPosts := len(listing.Data.Children)
matchedPosts := 0
log.Printf("scraper: got %d posts from listing", totalPosts)
for _, child := range listing.Data.Children {
post := child.Data
if !isF1Post(post.Title) {
log.Printf("scraper: skipped post: %s", truncate(post.Title, 60))
continue
}
matchedPosts++
log.Printf("scraper: matched post: %s", truncate(post.Title, 60))
selftextLinks := extractURLs(post.SelfText, post.Title)
log.Printf("scraper: extracted %d URLs from selftext of %q", len(selftextLinks), truncate(post.Title, 40))
for _, link := range selftextLinks {
norm := normalizeURL(link.URL)
if !seen[norm] {
seen[norm] = true
allLinks = append(allLinks, link)
}
}
time.Sleep(requestDelay)
commentsURL := fmt.Sprintf("https://www.reddit.com%s.json", post.Permalink)
comments, err := fetchJSONWithRetry[redditComments](client, commentsURL, 3)
if err != nil {
log.Printf("scraper: failed to fetch comments for %s: %v", post.Permalink, err)
continue
}
commentURLCount := 0
walkComments(*comments, func(body string) {
links := extractURLs(body, post.Title)
commentURLCount += len(links)
for _, link := range links {
norm := normalizeURL(link.URL)
if !seen[norm] {
seen[norm] = true
allLinks = append(allLinks, link)
}
}
})
log.Printf("scraper: extracted %d URLs from comments of %q", commentURLCount, truncate(post.Title, 40))
time.Sleep(requestDelay)
}
log.Printf("scraper: summary — matched %d/%d posts, extracted %d unique URLs", matchedPosts, totalPosts, len(allLinks))
return allLinks, nil
}
func fetchJSON[T any](client *http.Client, rawURL string) (*T, error) {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
log.Printf("scraper: GET %s -> %d", truncate(rawURL, 80), resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, err
}
var result T
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return &result, nil
}
func fetchJSONWithRetry[T any](client *http.Client, rawURL string, maxRetries int) (*T, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
result, err := fetchJSON[T](client, rawURL)
if err == nil {
return result, nil
}
lastErr = err
errMsg := err.Error()
if strings.Contains(errMsg, "status 429") {
log.Printf("scraper: rate limited on %s, backing off 30s", truncate(rawURL, 60))
time.Sleep(30 * time.Second)
continue
}
if strings.Contains(errMsg, "status 502") || strings.Contains(errMsg, "status 503") {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
log.Printf("scraper: server error on %s, retry %d/%d in %v", truncate(rawURL, 60), attempt+1, maxRetries, backoff)
time.Sleep(backoff)
continue
}
return nil, err
}
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
// deobfuscateText normalises obfuscated URLs commonly posted on Reddit to
// evade auto-moderation. Examples:
// - "pitsport . xyz/watch/f1" → "https://pitsport.xyz/watch/f1"
// - "dlhd dot link" → "https://dlhd.link"
func deobfuscateText(text string) string {
// Common TLDs used in streaming links.
tlds := `(?:com|net|org|xyz|link|info|live|tv|me|cc|to|io|co|stream|site|fun|top|club|watch|racing)`
// 1. Replace " dot " (case-insensitive) between word-like parts that
// look like domain components: "dlhd dot link" → "dlhd.link"
dotWord := regexp.MustCompile(`(?i)(\b\w[\w-]*)\s+dot\s+(` + tlds + `\b)`)
text = dotWord.ReplaceAllString(text, "${1}.${2}")
// 2. Collapse spaces around dots in domain-like strings:
// "pitsport . xyz" → "pitsport.xyz"
spaceDot := regexp.MustCompile(`(\b\w[\w-]*)\s*\.\s*(` + tlds + `\b)`)
text = spaceDot.ReplaceAllString(text, "${1}.${2}")
// 3. Prepend https:// to bare domain-like strings that the URL regex
// would otherwise miss (no scheme present).
bareDomain := regexp.MustCompile(`(?:^|[\s(>\[])(\w[\w-]*\.` + tlds + `(?:/[^\s)\]<"]*)?)`)
text = bareDomain.ReplaceAllStringFunc(text, func(m string) string {
// Preserve the leading whitespace/punctuation character.
trimmed := strings.TrimLeft(m, " \t\n(>[")
prefix := m[:len(m)-len(trimmed)]
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return m
}
return prefix + "https://" + trimmed
})
return text
}
func extractURLs(text, postTitle string) []models.ScrapedLink {
text = deobfuscateText(text)
matches := urlRe.FindAllString(text, -1)
var links []models.ScrapedLink
filtered := 0
for _, u := range matches {
u = strings.TrimRight(u, ".,;:!?)")
parsed, err := url.Parse(u)
if err != nil {
continue
}
if filteredDomains[parsed.Hostname()] {
filtered++
continue
}
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
continue
}
links = append(links, models.ScrapedLink{
ID: fmt.Sprintf("%x", id),
URL: u,
Title: postTitle,
Source: "r/motorsportsstreams2",
ScrapedAt: time.Now(),
})
}
if filtered > 0 {
log.Printf("scraper: filtered %d URLs from known domains in %q", filtered, truncate(postTitle, 40))
}
return links
}
func walkComments(comments redditComments, fn func(string)) {
for _, listing := range comments {
for _, child := range listing.Data.Children {
if child.Data.Body != "" {
fn(child.Data.Body)
}
// Recurse into replies
if len(child.Data.Replies) > 0 && child.Data.Replies[0] == '{' {
var nested redditComments
if err := json.Unmarshal([]byte("["+string(child.Data.Replies)+"]"), &nested); err == nil {
walkComments(nested, fn)
}
}
}
}
}
func normalizeURL(u string) string {
parsed, err := url.Parse(u)
if err != nil {
return strings.ToLower(u)
}
parsed.Host = strings.ToLower(parsed.Host)
path := strings.TrimRight(parsed.Path, "/")
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, path)
}
func isF1Post(title string) bool {
lower := strings.ToLower(title)
for _, neg := range f1NegativeKeywords {
if strings.Contains(lower, neg) {
return false
}
}
for _, kw := range f1Keywords {
if strings.Contains(lower, kw) {
return true
}
}
return false
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -0,0 +1,105 @@
package scraper
import (
"context"
"log"
"sync"
"time"
"f1-stream/internal/models"
"f1-stream/internal/store"
)
type Scraper struct {
store *store.Store
interval time.Duration
validateTimeout time.Duration
mu sync.Mutex
}
func New(s *store.Store, interval time.Duration, validateTimeout time.Duration) *Scraper {
return &Scraper{store: s, interval: interval, validateTimeout: validateTimeout}
}
func (s *Scraper) Run(ctx context.Context) {
log.Printf("scraper: starting with interval %v", s.interval)
// Run immediately on start
s.scrape()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("scraper: shutting down")
return
case <-ticker.C:
s.scrape()
}
}
}
func (s *Scraper) TriggerScrape() {
go s.scrape()
}
func (s *Scraper) scrape() {
s.mu.Lock()
defer s.mu.Unlock()
start := time.Now()
log.Println("scraper: starting scrape")
links, err := scrapeReddit()
if err != nil {
log.Printf("scraper: error after %v: %v", time.Since(start).Round(time.Millisecond), err)
return
}
log.Printf("scraper: reddit scrape completed in %v, got %d links", time.Since(start).Round(time.Millisecond), len(links))
// Merge with existing links, filtering out non-F1 entries
existing, err := s.store.LoadScrapedLinks()
if err != nil {
log.Printf("scraper: failed to load existing links: %v", err)
existing = nil
}
seen := make(map[string]bool)
var filtered []models.ScrapedLink
for _, l := range existing {
if !isF1Post(l.Title) {
continue
}
norm := normalizeURL(l.URL)
seen[norm] = true
filtered = append(filtered, l)
}
existing = filtered
added := 0
for _, l := range links {
norm := normalizeURL(l.URL)
if !seen[norm] {
existing = append(existing, l)
seen[norm] = true
added++
}
}
if err := s.store.SaveScrapedLinks(existing); err != nil {
log.Printf("scraper: failed to save: %v", err)
return
}
// Auto-publish newly validated links as streams
for _, l := range links {
if err := s.store.PublishScrapedStream(l.URL, l.Title); err != nil {
u := l.URL
if len(u) > 80 {
u = u[:80] + "..."
}
log.Printf("scraper: failed to auto-publish %s: %v", u, err)
}
}
log.Printf("scraper: done in %v, added %d new links (total: %d)", time.Since(start).Round(time.Millisecond), added, len(existing))
}

View file

@ -0,0 +1,142 @@
package scraper
import (
"io"
"log"
"net/http"
"strings"
"time"
"f1-stream/internal/models"
)
// videoMarkers are substrings checked (case-insensitively) against the HTML
// body to detect the presence of a video player or streaming manifest.
var videoMarkers = []string{
// HTML5 video element
"<video",
// HLS manifests
".m3u8",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
// DASH manifests
".mpd",
"application/dash+xml",
// Player libraries
"hls.js",
"hls.min.js",
"dash.js",
"dash.all.min.js",
"video.js",
"video.min.js",
"videojs",
"jwplayer",
"clappr",
"flowplayer",
"plyr",
"shaka-player",
"mediaelement",
"fluidplayer",
}
// videoContentTypes are Content-Type prefixes/substrings that indicate a
// direct video response (no HTML inspection needed).
var videoContentTypes = []string{
"video/",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
"application/dash+xml",
}
// validateBodyLimit caps how much HTML we read when looking for markers.
const validateBodyLimit = 2 * 1024 * 1024 // 2 MB
// validateLinks fetches each link and keeps only those whose response
// contains video/player content markers.
func validateLinks(links []models.ScrapedLink, timeout time.Duration) []models.ScrapedLink {
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
}
var kept []models.ScrapedLink
for _, link := range links {
if HasVideoContent(client, link.URL) {
kept = append(kept, link)
} else {
log.Printf("scraper: discarded %s (no video markers)", truncate(link.URL, 60))
}
}
return kept
}
// HasVideoContent performs a GET request for rawURL and returns true if the
// response is a direct video file (by Content-Type) or an HTML page that
// contains at least one video marker substring.
func HasVideoContent(client *http.Client, rawURL string) bool {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
log.Printf("scraper: validate request error for %s: %v", truncate(rawURL, 60), err)
return false
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
log.Printf("scraper: validate fetch error for %s: %v", truncate(rawURL, 60), err)
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false
}
ct := strings.ToLower(resp.Header.Get("Content-Type"))
// Direct video content type — no need to inspect body.
if isDirectVideoContentType(ct) {
return true
}
// Only inspect HTML pages for markers.
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "application/xhtml") {
return false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, validateBodyLimit))
if err != nil {
log.Printf("scraper: validate read error for %s: %v", truncate(rawURL, 60), err)
return false
}
return containsVideoMarkers(strings.ToLower(string(body)))
}
// containsVideoMarkers returns true if loweredBody contains any known video
// player or streaming marker substring.
func containsVideoMarkers(loweredBody string) bool {
for _, marker := range videoMarkers {
if strings.Contains(loweredBody, marker) {
return true
}
}
return false
}
// isDirectVideoContentType returns true if ct (already lowercased) matches a
// known video content type.
func isDirectVideoContentType(ct string) bool {
ct = strings.ToLower(ct)
for _, vct := range videoContentTypes {
if strings.Contains(ct, vct) {
return true
}
}
return false
}

View file

@ -0,0 +1,124 @@
package scraper
import "testing"
func TestContainsVideoMarkers(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
// Positive cases
{
name: "video tag",
body: `<div><video src="stream.mp4"></video></div>`,
want: true,
},
{
name: "HLS manifest reference",
body: `var url = "https://cdn.example.com/live.m3u8";`,
want: true,
},
{
name: "DASH manifest reference",
body: `<source src="stream.mpd" type="application/dash+xml">`,
want: true,
},
{
name: "HLS.js library",
body: `<script src="/js/hls.min.js"></script>`,
want: true,
},
{
name: "Video.js library",
body: `<script src="https://cdn.example.com/video.js"></script>`,
want: true,
},
{
name: "JW Player",
body: `<div id="jwplayer-container"></div><script>jwplayer("jwplayer-container")</script>`,
want: true,
},
{
name: "Clappr player",
body: `<script src="clappr.min.js"></script>`,
want: true,
},
{
name: "Flowplayer",
body: `<script>flowplayer("#player")</script>`,
want: true,
},
{
name: "Plyr player",
body: `<link rel="stylesheet" href="plyr.css"><script src="plyr.js"></script>`,
want: true,
},
{
name: "Shaka Player",
body: `<script src="shaka-player.compiled.js"></script>`,
want: true,
},
// Negative cases
{
name: "plain HTML",
body: `<html><body><p>Hello world</p></body></html>`,
want: false,
},
{
name: "reddit link page",
body: `<html><body><a href="https://example.com">Click here</a></body></html>`,
want: false,
},
{
name: "blog post",
body: `<html><body><article>F1 race results and analysis...</article></body></html>`,
want: false,
},
{
name: "empty string",
body: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := containsVideoMarkers(tt.body)
if got != tt.want {
t.Errorf("containsVideoMarkers(%q) = %v, want %v", truncate(tt.body, 60), got, tt.want)
}
})
}
}
func TestIsDirectVideoContentType(t *testing.T) {
tests := []struct {
name string
ct string
want bool
}{
// Positive cases
{name: "video/mp4", ct: "video/mp4", want: true},
{name: "video/webm", ct: "video/webm", want: true},
{name: "HLS content type", ct: "application/x-mpegurl", want: true},
{name: "Apple HLS content type", ct: "application/vnd.apple.mpegurl", want: true},
{name: "DASH content type", ct: "application/dash+xml", want: true},
{name: "video with params", ct: "video/mp4; charset=utf-8", want: true},
// Negative cases
{name: "text/html", ct: "text/html", want: false},
{name: "application/json", ct: "application/json", want: false},
{name: "image/png", ct: "image/png", want: false},
{name: "text/plain", ct: "text/plain", want: false},
{name: "empty string", ct: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDirectVideoContentType(tt.ct)
if got != tt.want {
t.Errorf("isDirectVideoContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
})
}
}

View file

@ -0,0 +1,93 @@
package server
import (
"log"
"net/http"
"strings"
"f1-stream/internal/auth"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// AuthMiddleware injects user into context if session cookie is present.
func AuthMiddleware(a *auth.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
user, err := a.GetSessionUser(cookie.Value)
if err == nil && user != nil {
r = r.WithContext(auth.ContextWithUser(r.Context(), user))
}
}
next.ServeHTTP(w, r)
})
}
}
// RequireAuth rejects unauthenticated requests.
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
next(w, r)
}
}
// RequireAdmin rejects non-admin requests.
func RequireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil || !user.IsAdmin {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next(w, r)
}
}
// OriginCheck validates Origin header on mutation requests (CSRF protection).
func OriginCheck(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
origin := r.Header.Get("Origin")
if origin != "" {
allowed := false
for _, o := range allowedOrigins {
if strings.EqualFold(origin, o) {
allowed = true
break
}
}
if !allowed {
http.Error(w, `{"error":"origin not allowed"}`, http.StatusForbidden)
return
}
}
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,293 @@
package server
import (
"encoding/json"
"html"
"log"
"net/http"
"strings"
"f1-stream/internal/auth"
"f1-stream/internal/extractor"
"f1-stream/internal/proxy"
"f1-stream/internal/scraper"
"f1-stream/internal/store"
)
type Server struct {
store *store.Store
auth *auth.Auth
scraper *scraper.Scraper
mux *http.ServeMux
headlessEnabled bool
}
func New(s *store.Store, a *auth.Auth, sc *scraper.Scraper, origins []string, headlessEnabled bool) *Server {
srv := &Server{
store: s,
auth: a,
scraper: sc,
mux: http.NewServeMux(),
headlessEnabled: headlessEnabled,
}
srv.registerRoutes(origins)
return srv
}
func (s *Server) Handler() http.Handler {
return s.mux
}
func (s *Server) registerRoutes(origins []string) {
// Apply middleware chain
authMw := AuthMiddleware(s.auth)
originMw := OriginCheck(origins)
// Static files
fs := http.FileServer(http.Dir("static"))
s.mux.Handle("GET /static/", http.StripPrefix("/static/", fs))
s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, "static/index.html")
})
// Health
s.mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Reverse proxy for iframe embedding (strips anti-framing headers)
proxyHandler := proxy.NewHandler()
s.mux.Handle("GET /proxy/", proxyHandler)
s.mux.Handle("POST /proxy/", proxyHandler)
s.mux.Handle("HEAD /proxy/", proxyHandler)
s.mux.Handle("OPTIONS /proxy/", proxyHandler)
// Public API - wrap with middleware
wrapAll := func(h http.HandlerFunc) http.Handler {
return RecoveryMiddleware(LoggingMiddleware(originMw(authMw(h))))
}
// Auth endpoints
s.mux.Handle("POST /api/auth/register/begin", wrapAll(s.auth.BeginRegistration))
s.mux.Handle("POST /api/auth/register/finish", wrapAll(s.auth.FinishRegistration))
s.mux.Handle("POST /api/auth/login/begin", wrapAll(s.auth.BeginLogin))
s.mux.Handle("POST /api/auth/login/finish", wrapAll(s.auth.FinishLogin))
s.mux.Handle("POST /api/auth/logout", wrapAll(s.auth.Logout))
s.mux.Handle("GET /api/auth/me", wrapAll(s.auth.Me))
// Public streams
s.mux.Handle("GET /api/streams/public", wrapAll(s.handlePublicStreams))
s.mux.Handle("GET /api/streams/{id}/browse", wrapAll(s.handleBrowseStream))
// Scraped links
s.mux.Handle("GET /api/scraped", wrapAll(s.handleScrapedLinks))
s.mux.Handle("POST /api/scraped/refresh", wrapAll(s.handleTriggerScrape))
s.mux.Handle("POST /api/scraped/{id}/import", wrapAll(s.handleImportScraped))
// Authenticated endpoints
s.mux.Handle("GET /api/streams/mine", wrapAll(RequireAuth(s.handleMyStreams)))
s.mux.Handle("POST /api/streams", wrapAll(s.handleSubmitStream))
s.mux.Handle("DELETE /api/streams/{id}", wrapAll(s.handleDeleteStream))
// Admin endpoints
s.mux.Handle("PUT /api/streams/{id}/publish", wrapAll(RequireAdmin(s.handleTogglePublish)))
s.mux.Handle("GET /api/admin/streams", wrapAll(RequireAdmin(s.handleAllStreams)))
s.mux.Handle("POST /api/admin/scrape", wrapAll(RequireAdmin(s.handleTriggerScrape)))
}
func (s *Server) handlePublicStreams(w http.ResponseWriter, r *http.Request) {
streams, err := s.store.PublicStreams()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleScrapedLinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetActiveScrapedLinks()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if links == nil {
w.Write([]byte("[]"))
return
}
json.NewEncoder(w).Encode(links)
}
func (s *Server) handleImportScraped(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
link, err := s.store.GetScrapedLinkByID(id)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if err := s.store.PublishScrapedStream(link.URL, link.Title); err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleMyStreams(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
streams, err := s.store.UserStreams(user.ID)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if streams == nil {
w.Write([]byte("[]"))
return
}
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleSubmitStream(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
var req struct {
URL string `json:"url"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
req.URL = strings.TrimSpace(req.URL)
req.Title = strings.TrimSpace(req.Title)
if req.URL == "" {
http.Error(w, `{"error":"url required"}`, http.StatusBadRequest)
return
}
if len(req.URL) > 2048 {
http.Error(w, `{"error":"url too long"}`, http.StatusBadRequest)
return
}
if !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "http://") {
http.Error(w, `{"error":"url must start with http:// or https://"}`, http.StatusBadRequest)
return
}
if req.Title == "" {
req.Title = req.URL
}
req.Title = html.EscapeString(req.Title)
submittedBy := "anonymous"
published := true
if user != nil {
submittedBy = user.ID
published = false
}
stream, err := s.store.AddStream(req.URL, req.Title, submittedBy, published, "user")
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(stream)
}
func (s *Server) handleDeleteStream(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
var userID string
var isAdmin bool
if user != nil {
userID = user.ID
isAdmin = user.IsAdmin
}
if err := s.store.DeleteStream(id, userID, isAdmin); err != nil {
if strings.Contains(err.Error(), "not authorized") {
http.Error(w, `{"error":"not authorized"}`, http.StatusForbidden)
return
}
if strings.Contains(err.Error(), "not found") {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleTogglePublish(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.TogglePublish(id); err != nil {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleAllStreams(w http.ResponseWriter, r *http.Request) {
streams, err := s.store.LoadStreams()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(streams)
}
func (s *Server) handleTriggerScrape(w http.ResponseWriter, r *http.Request) {
s.scraper.TriggerScrape()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"message":"scrape triggered"}`))
}
func (s *Server) handleBrowseStream(w http.ResponseWriter, r *http.Request) {
if !s.headlessEnabled {
http.Error(w, `{"error":"browser sessions not available"}`, http.StatusNotFound)
return
}
id := r.PathValue("id")
streams, err := s.store.LoadStreams()
if err != nil {
log.Printf("server: browse: failed to load streams: %v", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
var streamURL string
var found bool
for _, st := range streams {
if st.ID == id {
if !st.Published {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
streamURL = st.URL
found = true
break
}
}
if !found {
http.Error(w, `{"error":"stream not found"}`, http.StatusNotFound)
return
}
extractor.HandleBrowserSession(w, r, streamURL)
}

View file

@ -0,0 +1,37 @@
package store
import (
"f1-stream/internal/models"
)
func (s *Store) LoadHealthStates() ([]models.HealthState, error) {
s.healthMu.RLock()
defer s.healthMu.RUnlock()
var states []models.HealthState
if err := readJSON(s.filePath("health_state.json"), &states); err != nil {
return nil, err
}
return states, nil
}
func (s *Store) SaveHealthStates(states []models.HealthState) error {
s.healthMu.Lock()
defer s.healthMu.Unlock()
return writeJSON(s.filePath("health_state.json"), states)
}
// HealthMap returns a map of URL -> Healthy status. It reads the health state
// file directly without acquiring healthMu to avoid deadlock when called from
// methods that already hold other locks (e.g., PublicStreams, GetActiveScrapedLinks).
// URLs not present in the map are implicitly healthy.
func (s *Store) HealthMap() map[string]bool {
var states []models.HealthState
if err := readJSON(s.filePath("health_state.json"), &states); err != nil {
return make(map[string]bool)
}
m := make(map[string]bool, len(states))
for _, st := range states {
m[st.URL] = st.Healthy
}
return m
}

View file

@ -0,0 +1,63 @@
package store
import (
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadScrapedLinks() ([]models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return nil, err
}
return links, nil
}
func (s *Store) SaveScrapedLinks(links []models.ScrapedLink) error {
s.scrapedMu.Lock()
defer s.scrapedMu.Unlock()
return writeJSON(s.filePath("scraped_links.json"), links)
}
func (s *Store) GetScrapedLinkByID(id string) (models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return models.ScrapedLink{}, err
}
for _, l := range links {
if l.ID == id {
return l, nil
}
}
return models.ScrapedLink{}, fmt.Errorf("not found")
}
func (s *Store) GetActiveScrapedLinks() ([]models.ScrapedLink, error) {
s.scrapedMu.RLock()
defer s.scrapedMu.RUnlock()
var links []models.ScrapedLink
if err := readJSON(s.filePath("scraped_links.json"), &links); err != nil {
return nil, err
}
healthMap := s.HealthMap()
now := time.Now()
var active []models.ScrapedLink
for _, l := range links {
l.Stale = now.Sub(l.ScrapedAt) > 7*24*time.Hour
if l.Stale {
continue
}
// Filter unhealthy scraped links. URLs not in healthMap are assumed healthy.
if healthy, exists := healthMap[l.URL]; exists && !healthy {
continue
}
active = append(active, l)
}
return active, nil
}

View file

@ -0,0 +1,98 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadSessions() ([]models.Session, error) {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *Store) CreateSession(userID string, ttl time.Duration) (string, error) {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return "", err
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
sess := models.Session{
Token: token,
UserID: userID,
ExpiresAt: time.Now().Add(ttl),
}
sessions = append(sessions, sess)
if err := writeJSON(s.filePath("sessions.json"), sessions); err != nil {
return "", err
}
return token, nil
}
func (s *Store) GetSession(token string) (*models.Session, error) {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return nil, err
}
for _, sess := range sessions {
if sess.Token == token && time.Now().Before(sess.ExpiresAt) {
return &sess, nil
}
}
return nil, nil
}
func (s *Store) DeleteSession(token string) error {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return err
}
var updated []models.Session
found := false
for _, sess := range sessions {
if sess.Token == token {
found = true
continue
}
updated = append(updated, sess)
}
if !found {
return fmt.Errorf("session not found")
}
return writeJSON(s.filePath("sessions.json"), updated)
}
func (s *Store) CleanExpiredSessions() error {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
var sessions []models.Session
if err := readJSON(s.filePath("sessions.json"), &sessions); err != nil {
return err
}
now := time.Now()
var valid []models.Session
for _, sess := range sessions {
if now.Before(sess.ExpiresAt) {
valid = append(valid, sess)
}
}
return writeJSON(s.filePath("sessions.json"), valid)
}

View file

@ -0,0 +1,53 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
type Store struct {
dir string
streamsMu sync.RWMutex
usersMu sync.RWMutex
scrapedMu sync.RWMutex
sessionsMu sync.RWMutex
healthMu sync.RWMutex
}
func New(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
func (s *Store) filePath(name string) string {
return filepath.Join(s.dir, name)
}
// readJSON reads a JSON file into the target. Returns nil if file doesn't exist.
func readJSON(path string, target interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, target)
}
// writeJSON atomically writes target as JSON to path using temp-file-then-rename.
func writeJSON(path string, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View file

@ -0,0 +1,176 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"f1-stream/internal/models"
)
func (s *Store) LoadStreams() ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
return streams, nil
}
func (s *Store) PublicStreams() ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
healthMap := s.HealthMap()
var pub []models.Stream
for _, st := range streams {
if !st.Published {
continue
}
// Filter unhealthy streams. URLs not in healthMap are assumed healthy (new/unchecked).
if healthy, exists := healthMap[st.URL]; exists && !healthy {
continue
}
pub = append(pub, st)
}
return pub, nil
}
func (s *Store) UserStreams(userID string) ([]models.Stream, error) {
s.streamsMu.RLock()
defer s.streamsMu.RUnlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return nil, err
}
var result []models.Stream
for _, st := range streams {
if st.SubmittedBy == userID {
result = append(result, st)
}
}
return result, nil
}
func (s *Store) AddStream(url, title, submittedBy string, published bool, source string) (models.Stream, error) {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return models.Stream{}, err
}
id, err := randomID()
if err != nil {
return models.Stream{}, err
}
st := models.Stream{
ID: id,
URL: url,
Title: title,
SubmittedBy: submittedBy,
Published: published,
Source: source,
CreatedAt: time.Now(),
}
streams = append(streams, st)
if err := writeJSON(s.filePath("streams.json"), streams); err != nil {
return models.Stream{}, err
}
return st, nil
}
func (s *Store) PublishScrapedStream(url, title string) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
// Deduplicate: skip if URL already exists in streams
for _, st := range streams {
if st.URL == url {
return nil
}
}
id, err := randomID()
if err != nil {
return err
}
streams = append(streams, models.Stream{
ID: id,
URL: url,
Title: title,
SubmittedBy: "scraper",
Published: true,
Source: "scraped",
CreatedAt: time.Now(),
})
return writeJSON(s.filePath("streams.json"), streams)
}
func (s *Store) DeleteStream(id, userID string, isAdmin bool) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
var updated []models.Stream
found := false
for _, st := range streams {
if st.ID == id {
if userID != "" && !isAdmin && st.SubmittedBy != userID {
return fmt.Errorf("not authorized")
}
found = true
continue
}
updated = append(updated, st)
}
if !found {
return fmt.Errorf("stream not found")
}
return writeJSON(s.filePath("streams.json"), updated)
}
func (s *Store) TogglePublish(id string) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var streams []models.Stream
if err := readJSON(s.filePath("streams.json"), &streams); err != nil {
return err
}
for i, st := range streams {
if st.ID == id {
streams[i].Published = !st.Published
return writeJSON(s.filePath("streams.json"), streams)
}
}
return fmt.Errorf("stream not found")
}
func (s *Store) SeedStreams(defaults []models.Stream) error {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
var existing []models.Stream
if err := readJSON(s.filePath("streams.json"), &existing); err != nil {
return err
}
if len(existing) > 0 {
return nil
}
return writeJSON(s.filePath("streams.json"), defaults)
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View file

@ -0,0 +1,91 @@
package store
import (
"fmt"
"f1-stream/internal/models"
"github.com/go-webauthn/webauthn/webauthn"
)
func (s *Store) LoadUsers() ([]models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
return users, nil
}
func (s *Store) GetUserByName(username string) (*models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
for _, u := range users {
if u.Username == username {
return &u, nil
}
}
return nil, nil
}
func (s *Store) GetUserByID(id string) (*models.User, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return nil, err
}
for _, u := range users {
if u.ID == id {
return &u, nil
}
}
return nil, nil
}
func (s *Store) CreateUser(user models.User) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return err
}
for _, u := range users {
if u.Username == user.Username {
return fmt.Errorf("username already exists")
}
}
users = append(users, user)
return writeJSON(s.filePath("users.json"), users)
}
func (s *Store) UpdateUserCredentials(userID string, creds []webauthn.Credential) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return err
}
for i, u := range users {
if u.ID == userID {
users[i].Credentials = creds
return writeJSON(s.filePath("users.json"), users)
}
}
return fmt.Errorf("user not found")
}
func (s *Store) UserCount() (int, error) {
s.usersMu.RLock()
defer s.usersMu.RUnlock()
var users []models.User
if err := readJSON(s.filePath("users.json"), &users); err != nil {
return 0, err
}
return len(users), nil
}

View file

@ -0,0 +1,161 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"f1-stream/internal/auth"
"f1-stream/internal/extractor"
"f1-stream/internal/healthcheck"
"f1-stream/internal/models"
"f1-stream/internal/scraper"
"f1-stream/internal/server"
"f1-stream/internal/store"
)
func main() {
listenAddr := envOr("LISTEN_ADDR", ":8080")
dataDir := envOr("DATA_DIR", "/data")
scrapeInterval := envDuration("SCRAPE_INTERVAL", 15*time.Minute)
validateTimeout := envDuration("SCRAPER_VALIDATE_TIMEOUT", 10*time.Second)
adminUsername := os.Getenv("ADMIN_USERNAME")
sessionTTL := envDuration("SESSION_TTL", 720*time.Hour)
headlessEnabled := os.Getenv("HEADLESS_EXTRACT_ENABLED") == "true"
rpID := envOr("WEBAUTHN_RPID", "localhost")
rpOrigin := envOr("WEBAUTHN_ORIGIN", "http://localhost:8080")
rpDisplayName := envOr("WEBAUTHN_DISPLAY_NAME", "F1 Stream")
// Initialize store
st, err := store.New(dataDir)
if err != nil {
log.Fatalf("failed to init store: %v", err)
}
// Seed default streams
if err := st.SeedStreams(defaultStreams()); err != nil {
log.Printf("warning: failed to seed streams: %v", err)
}
// Initialize auth
origins := strings.Split(rpOrigin, ",")
a, err := auth.New(st, rpDisplayName, rpID, origins, adminUsername, sessionTTL)
if err != nil {
log.Fatalf("failed to init auth: %v", err)
}
// Initialize scraper
sc := scraper.New(st, scrapeInterval, validateTimeout)
// Initialize health checker
healthInterval := envDuration("HEALTH_CHECK_INTERVAL", 5*time.Minute)
healthTimeout := envDuration("HEALTH_CHECK_TIMEOUT", 10*time.Second)
hc := healthcheck.New(st, healthInterval, healthTimeout)
// Initialize server
srv := server.New(st, a, sc, origins, headlessEnabled)
// Start scraper in background
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Initialize headless browser if enabled
if headlessEnabled {
extractor.Init()
defer extractor.Stop()
// Configure TURN server if provided
if turnURL := os.Getenv("TURN_URL"); turnURL != "" {
turnSecret := os.Getenv("TURN_SHARED_SECRET")
turnInternalURL := os.Getenv("TURN_INTERNAL_URL")
extractor.SetTURNConfig(turnURL, turnSecret, turnInternalURL)
}
log.Println("headless video extraction enabled")
}
go sc.Run(ctx)
go hc.Run(ctx)
// Clean expired sessions periodically
go func() {
sessionTicker := time.NewTicker(1 * time.Hour)
defer sessionTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-sessionTicker.C:
st.CleanExpiredSessions()
}
}
}()
httpSrv := &http.Server{
Addr: listenAddr,
Handler: srv.Handler(),
}
go func() {
<-ctx.Done()
log.Println("shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
httpSrv.Shutdown(shutdownCtx)
}()
log.Printf("starting server on %s", listenAddr)
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
log.Println("server stopped")
}
func defaultStreams() []models.Stream {
now := time.Now()
streams := []struct {
url, title string
}{
{"https://wearechecking.live/streams-pages/motorsports", "WeAreChecking - Motorsports"},
{"https://vipleague.im/formula-1-schedule-streaming-links", "VIPLeague - F1"},
{"https://www.vipbox.lc/", "VIPBox"},
{"https://f1box.me/", "F1Box"},
{"https://1stream.vip/formula-1-streams/", "1Stream - F1"},
}
var result []models.Stream
for i, s := range streams {
result = append(result, models.Stream{
ID: fmt.Sprintf("default-%d", i),
URL: s.url,
Title: s.title,
SubmittedBy: "system",
Published: true,
Source: "system",
CreatedAt: now,
})
}
return result
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
log.Printf("warning: invalid %s=%q, using default %v", key, v, fallback)
return fallback
}
return d
}
return fallback
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>F1 Streams</title>
<meta name="description" content="Live F1 streaming links aggregated from Reddit and user submissions">
<meta property="og:title" content="F1 Streams">
<meta property="og:description" content="Live F1 streaming links aggregated from Reddit and user submissions">
<meta property="og:type" content="website">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23e10600' rx='12' width='100' height='100'/><text x='50' y='72' font-size='60' font-weight='900' text-anchor='middle' fill='white' font-family='sans-serif'>F1</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Titillium+Web:wght@400;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/pico.min.css">
<link rel="stylesheet" href="/static/css/custom.css">
</head>
<body>
<header>
<div class="header-left">
<span class="f1-logo">F1</span>
<div>
<h1 class="brand-title">Streams</h1>
<div class="brand-subtitle">Live Racing Hub</div>
</div>
<span class="live-indicator" id="live-badge" hidden>
<span class="live-dot"></span>
LIVE
</span>
</div>
<div class="auth-section" id="auth-section">
<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>
</div>
</header>
<div class="racing-stripe"></div>
<nav class="tabs" id="tabs">
<button class="hamburger" id="hamburger" onclick="toggleMobileNav()" aria-label="Toggle navigation">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="tab-btn active" data-tab="streams" onclick="switchTab('streams')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Streams
</button>
<button class="tab-btn" data-tab="reddit" onclick="switchTab('reddit')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(90 12 12)"/></svg>
Reddit Links
</button>
<button class="tab-btn hidden" data-tab="mine" onclick="switchTab('mine')" id="tab-mine">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
My Streams
</button>
<button class="tab-btn hidden" data-tab="admin" onclick="switchTab('admin')" id="tab-admin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Admin
</button>
</nav>
<main>
<section class="tab-content active" id="content-streams">
<div class="submit-form-card">
<div class="submit-form">
<input type="url" id="public-submit-url" placeholder="https://stream-url.com/..." required>
<input type="text" id="public-submit-title" placeholder="Stream title (optional)">
<button onclick="addPublicStream()">Add Stream</button>
</div>
</div>
<div class="stream-grid" id="stream-grid"></div>
<div class="empty-state" id="streams-empty" style="display:none">
<span class="empty-icon">&#127937;</span>
<div class="empty-title">No Streams Yet</div>
<p class="empty-desc">Add a stream URL above to get the race started.</p>
</div>
</section>
<section class="tab-content" id="content-reddit">
<div class="section-header">
<h3>Reddit Links</h3>
<button onclick="refreshRedditLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Refresh
</button>
</div>
<ul class="link-list" id="reddit-list"></ul>
<div class="empty-state" id="reddit-empty" style="display:none">
<span class="empty-icon">&#128225;</span>
<div class="empty-title">No Links Found</div>
<p class="empty-desc">No Reddit links scraped yet. Check back closer to race time.</p>
</div>
</section>
<section class="tab-content" id="content-mine">
<div class="submit-form-card">
<div class="submit-form">
<input type="url" id="submit-url" placeholder="https://stream-url.com/..." required>
<input type="text" id="submit-title" placeholder="Stream title (optional)">
<button onclick="submitStream()">Submit Stream</button>
</div>
</div>
<div class="stream-grid" id="my-stream-grid"></div>
<div class="empty-state" id="mine-empty" style="display:none">
<span class="empty-icon">&#127918;</span>
<div class="empty-title">Your Pit Lane is Empty</div>
<p class="empty-desc">Submit a stream URL above to join the grid.</p>
</div>
</section>
<section class="tab-content" id="content-admin">
<div class="section-header">
<h3>All Streams</h3>
<button onclick="triggerScrape()">Trigger Scrape</button>
</div>
<div class="admin-stats" id="admin-stats"></div>
<div id="admin-stream-list"></div>
</section>
<!-- Browser Session Viewer (inline within main) -->
<section id="browser-viewer" class="browser-viewer hidden">
<div class="browser-viewer-bar">
<div class="browser-url-bar">
<svg class="browser-url-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="browser-url-text" id="browser-url"></span>
</div>
<span class="browser-viewer-status"></span>
<a id="browser-open-original" href="#" target="_blank" rel="noopener" class="browser-open-btn" title="Open original in new tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button class="browser-viewer-close" onclick="closeBrowserSession()" title="Close viewer">&times;</button>
</div>
<div class="browser-viewer-content">
<div class="loading-overlay" id="browser-viewer-loader">
<div class="spinner"></div>
</div>
</div>
</section>
</main>
<footer>
<p>Stream links are user-submitted and scraped from Reddit. No streams are hosted on this site.</p>
</footer>
<!-- Auth Dialog -->
<dialog id="auth-dialog">
<article>
<button class="dialog-close" onclick="document.getElementById('auth-dialog').close()" aria-label="Close">&times;</button>
<div class="dialog-logo"><span class="f1-logo">F1</span></div>
<h3 class="dialog-title">Welcome</h3>
<p class="dialog-subtitle">Sign in with your passkey to manage streams</p>
<div class="dialog-tabs">
<button class="dialog-tab-btn active" onclick="switchAuthTab('login', event)">Login</button>
<button class="dialog-tab-btn" onclick="switchAuthTab('register', event)">Register</button>
</div>
<div id="auth-login-form" class="auth-form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" placeholder="Username" autocomplete="username webauthn">
<div class="error-msg" id="login-error"></div>
<button onclick="doLogin()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Login with Passkey
</button>
</div>
<div id="auth-register-form" class="auth-form-group" style="display:none">
<label for="register-username">Username</label>
<input type="text" id="register-username" placeholder="Username (3-30 chars)" autocomplete="username">
<div class="error-msg" id="register-error"></div>
<button onclick="doRegister()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
Register with Passkey
</button>
</div>
<button onclick="document.getElementById('auth-dialog').close()" class="btn-secondary dialog-cancel">Cancel</button>
</article>
</dialog>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<!-- Reddit Viewer Overlay -->
<div id="reddit-viewer" class="reddit-viewer hidden">
<div class="reddit-viewer-bar">
<span class="reddit-viewer-title"></span>
<button class="reddit-viewer-close" onclick="closeRedditViewer()">&times;</button>
</div>
<div class="reddit-viewer-content">
<div class="loading-overlay" id="reddit-viewer-loader">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Browser Session Viewer (inline, inside main via JS) -->
<script src="/static/js/utils.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/streams.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,121 @@
// Toast notification system
const TOAST_ICONS = {
success: '\u2705',
error: '\u274C',
warning: '\u26A0\uFE0F',
info: '\u2139\uFE0F'
};
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${TOAST_ICONS[type] || TOAST_ICONS.info}</span>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="dismissToast(this.parentElement)">&times;</button>
`;
container.appendChild(toast);
if (duration > 0) {
setTimeout(() => dismissToast(toast), duration);
}
}
function dismissToast(toast) {
if (!toast || toast.classList.contains('toast-out')) return;
toast.classList.add('toast-out');
toast.addEventListener('animationend', () => toast.remove());
}
// Confirm dialog (replaces window.confirm)
function showConfirm(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<div class="confirm-msg">${escapeHtml(message)}</div>
<div class="confirm-actions">
<button class="btn-secondary" id="confirm-cancel">Cancel</button>
<button class="btn-primary" id="confirm-ok">Confirm</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-ok').addEventListener('click', () => {
overlay.remove();
resolve(true);
});
overlay.querySelector('#confirm-cancel').addEventListener('click', () => {
overlay.remove();
resolve(false);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
resolve(false);
}
});
});
}
// Mobile nav hamburger toggle
function toggleMobileNav() {
const tabs = document.getElementById('tabs');
tabs.classList.toggle('open');
}
// Tab switching
function switchTab(tab) {
closeRedditViewer();
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'content-' + tab);
});
// Close mobile nav
document.getElementById('tabs').classList.remove('open');
// Load data for the tab
switch (tab) {
case 'streams':
loadPublicStreams();
break;
case 'reddit':
loadRedditLinks();
break;
case 'mine':
loadMyStreams();
break;
case 'admin':
loadAdminStreams();
break;
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
checkAuth();
await loadPublicStreams();
const grid = document.getElementById('stream-grid');
const badge = document.getElementById('live-badge');
if (badge && grid && grid.children.length > 0) {
badge.hidden = false;
}
});
// Close Reddit viewer on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const viewer = document.getElementById('reddit-viewer');
if (viewer && !viewer.classList.contains('hidden')) {
closeRedditViewer();
}
}
});

View file

@ -0,0 +1,219 @@
// WebAuthn helper: base64url encode/decode
function bufToBase64url(buf) {
const bytes = new Uint8Array(buf);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function base64urlToBuf(b64) {
const pad = b64.length % 4;
if (pad) b64 += '='.repeat(4 - pad);
const str = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const buf = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
return buf.buffer;
}
let currentUser = null;
function showAuthDialog() {
document.getElementById('auth-dialog').showModal();
}
function switchAuthTab(tab, evt) {
const btns = document.querySelectorAll('.dialog-tab-btn');
btns.forEach(b => b.classList.remove('active'));
evt.target.classList.add('active');
document.getElementById('auth-login-form').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('auth-register-form').style.display = tab === 'register' ? 'block' : 'none';
document.getElementById('login-error').textContent = '';
document.getElementById('register-error').textContent = '';
}
async function doRegister() {
const username = document.getElementById('register-username').value.trim();
const errEl = document.getElementById('register-error');
errEl.textContent = '';
if (!username || username.length < 3) {
errEl.textContent = 'Username must be at least 3 characters';
return;
}
try {
// Step 1: Begin registration
const beginResp = await fetch('/api/auth/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!beginResp.ok) {
const err = await beginResp.json();
errEl.textContent = err.error || 'Registration failed';
return;
}
const options = await beginResp.json();
// Convert base64url fields to ArrayBuffers
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
options.publicKey.user.id = base64urlToBuf(options.publicKey.user.id);
if (options.publicKey.excludeCredentials) {
options.publicKey.excludeCredentials = options.publicKey.excludeCredentials.map(c => ({
...c,
id: base64urlToBuf(c.id)
}));
}
// Step 2: Create credential via browser
const credential = await navigator.credentials.create(options);
// Step 3: Finish registration
const attestation = {
id: credential.id,
rawId: bufToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufToBase64url(credential.response.attestationObject),
clientDataJSON: bufToBase64url(credential.response.clientDataJSON)
}
};
const finishResp = await fetch(`/api/auth/register/finish?username=${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation)
});
if (!finishResp.ok) {
const err = await finishResp.json();
errEl.textContent = err.error || 'Registration failed';
return;
}
const user = await finishResp.json();
setLoggedIn(user);
document.getElementById('auth-dialog').close();
} catch (e) {
console.error('Registration error:', e);
errEl.textContent = e.message || 'Registration failed';
}
}
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const errEl = document.getElementById('login-error');
errEl.textContent = '';
if (!username) {
errEl.textContent = 'Username required';
return;
}
try {
// Step 1: Begin login
const beginResp = await fetch('/api/auth/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!beginResp.ok) {
const err = await beginResp.json();
errEl.textContent = err.error || 'Login failed';
return;
}
const options = await beginResp.json();
// Convert base64url fields
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
if (options.publicKey.allowCredentials) {
options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
...c,
id: base64urlToBuf(c.id)
}));
}
// Step 2: Get assertion via browser
const assertion = await navigator.credentials.get(options);
// Step 3: Finish login
const assertionData = {
id: assertion.id,
rawId: bufToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufToBase64url(assertion.response.clientDataJSON),
signature: bufToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ? bufToBase64url(assertion.response.userHandle) : ''
}
};
const finishResp = await fetch(`/api/auth/login/finish?username=${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionData)
});
if (!finishResp.ok) {
const err = await finishResp.json();
errEl.textContent = err.error || 'Login failed';
return;
}
const user = await finishResp.json();
setLoggedIn(user);
document.getElementById('auth-dialog').close();
} catch (e) {
console.error('Login error:', e);
errEl.textContent = e.message || 'Login failed';
}
}
async function doLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
setLoggedOut();
}
function setLoggedIn(user) {
currentUser = user;
const section = document.getElementById('auth-section');
section.innerHTML = `
<span>Hi, ${escapeHtml(user.username)}</span>
<button onclick="doLogout()">Logout</button>
`;
document.getElementById('tab-mine').classList.remove('hidden');
if (user.is_admin) {
document.getElementById('tab-admin').classList.remove('hidden');
}
}
function setLoggedOut() {
currentUser = null;
const section = document.getElementById('auth-section');
section.innerHTML = '<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>';
document.getElementById('tab-mine').classList.add('hidden');
document.getElementById('tab-admin').classList.add('hidden');
// Switch to streams tab if on a protected tab
const activeTab = document.querySelector('.tab-btn.active');
if (activeTab && (activeTab.dataset.tab === 'mine' || activeTab.dataset.tab === 'admin')) {
switchTab('streams');
}
}
async function checkAuth() {
try {
const resp = await fetch('/api/auth/me');
if (resp.ok) {
const user = await resp.json();
setLoggedIn(user);
}
} catch (e) {
// Not logged in
}
}

View file

@ -0,0 +1,397 @@
async function loadPublicStreams() {
const grid = document.getElementById('stream-grid');
const empty = document.getElementById('streams-empty');
try {
const resp = await fetch('/api/streams/public');
const streams = await resp.json();
if (!streams || streams.length === 0) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
grid.innerHTML = streams.map(s => streamCard(s, !!currentUser)).join('');
} catch (e) {
console.error('Failed to load streams:', e);
grid.innerHTML = '';
empty.style.display = '';
}
}
async function loadMyStreams() {
const grid = document.getElementById('my-stream-grid');
const empty = document.getElementById('mine-empty');
try {
const resp = await fetch('/api/streams/mine');
const streams = await resp.json();
if (!streams || streams.length === 0) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
grid.innerHTML = streams.map(s => streamCard(s, true)).join('');
} catch (e) {
console.error('Failed to load my streams:', e);
}
}
async function loadRedditLinks() {
const list = document.getElementById('reddit-list');
const empty = document.getElementById('reddit-empty');
try {
const [scrapedResp, streamsResp] = await Promise.all([
fetch('/api/scraped'),
fetch('/api/streams/public')
]);
const links = await scrapedResp.json();
const streams = await streamsResp.json();
const importedURLs = new Set((streams || []).map(s => s.url));
if (!links || links.length === 0) {
list.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
list.innerHTML = links.map(l => {
const imported = importedURLs.has(l.url);
const actionHtml = imported
? `<span class="badge badge-imported">Imported</span>`
: `<button class="btn-import" onclick="importRedditLink('${escapeHtml(l.id)}')">Import</button>`;
return `
<li>
<span class="link-source-badge">${escapeHtml(l.source)}</span>
<div class="link-title">
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener">${escapeHtml(l.title || l.url)}</a>
</div>
${actionHtml}
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="link-open-icon-wrap" title="Open in new tab">
<svg class="link-open-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</li>
`;
}).join('');
} catch (e) {
console.error('Failed to load Reddit links:', e);
}
}
async function importRedditLink(id) {
try {
const resp = await fetch(`/api/scraped/${id}/import`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to import', 'error');
return;
}
showToast('Stream imported', 'success');
loadRedditLinks();
loadPublicStreams();
} catch (e) {
showToast('Failed to import stream', 'error');
}
}
async function loadAdminStreams() {
const container = document.getElementById('admin-stream-list');
const statsContainer = document.getElementById('admin-stats');
try {
const resp = await fetch('/api/admin/streams');
const streams = await resp.json();
if (!streams || streams.length === 0) {
statsContainer.innerHTML = '';
container.innerHTML = '<div class="empty-state"><span class="empty-icon">&#128203;</span><div class="empty-title">No Streams</div><p class="empty-desc">No streams have been submitted yet.</p></div>';
return;
}
const total = streams.length;
const published = streams.filter(s => s.published).length;
const drafts = total - published;
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-number">${total}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-number">${published}</div>
<div class="stat-label">Published</div>
</div>
<div class="stat-card">
<div class="stat-number">${drafts}</div>
<div class="stat-label">Drafts</div>
</div>
`;
container.innerHTML = streams.map(s => `
<div class="admin-stream">
<div class="info">
<span class="status-dot ${s.published ? 'published' : 'draft'}"></span>
<div class="stream-details">
<div class="stream-title">
${escapeHtml(s.title)}
<span class="badge ${s.published ? 'badge-published' : 'badge-draft'}">
${s.published ? 'Published' : 'Draft'}
</span>
</div>
<div class="stream-url">${escapeHtml(s.url)}</div>
${s.submitted_by ? `<div class="stream-submitter">by ${escapeHtml(s.submitted_by)}</div>` : ''}
</div>
</div>
<div class="actions">
<button onclick="togglePublish('${s.id}')" class="${s.published ? 'btn-secondary-sm' : 'btn-primary-sm'}">
${s.published ? 'Unpublish' : 'Publish'}
</button>
<button onclick="deleteStream('${s.id}', true)" class="btn-danger-sm">Delete</button>
</div>
</div>
`).join('');
} catch (e) {
console.error('Failed to load admin streams:', e);
}
}
function streamCard(stream, canDelete) {
const deleteBtn = canDelete
? `<button onclick="event.stopPropagation(); deleteStream('${stream.id}', false)" class="icon-btn danger" title="Delete stream">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>`
: '';
return `
<div class="stream-card" data-stream-id="${stream.id}"
onclick="openBrowserSession('${stream.id}', '${escapeAttr(stream.title)}', '${escapeAttr(stream.url)}')">
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="card-title">${escapeHtml(stream.title)}</div>
<div class="card-url">${escapeHtml(stream.url)}</div>
</div>
<div class="card-bar">
<div class="card-actions">
<a href="${escapeHtml(stream.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" class="icon-btn" title="Open original">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
${deleteBtn}
</div>
</div>
</div>
`;
}
async function _submitStreamCommon(urlId, titleId, successMsg, reloadFn) {
const urlInput = document.getElementById(urlId);
const titleInput = document.getElementById(titleId);
const url = urlInput.value.trim();
const title = titleInput.value.trim();
if (!url) {
showToast('URL is required', 'warning');
return;
}
try {
new URL(url);
} catch {
showToast('Please enter a valid URL', 'warning');
return;
}
try {
const resp = await fetch('/api/streams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, title })
});
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to add stream', 'error');
return;
}
urlInput.value = '';
titleInput.value = '';
showToast(successMsg, 'success');
reloadFn();
} catch (e) {
showToast('Failed to add stream', 'error');
}
}
async function addPublicStream() {
await _submitStreamCommon('public-submit-url', 'public-submit-title', 'Stream added', loadPublicStreams);
}
async function submitStream() {
await _submitStreamCommon('submit-url', 'submit-title', 'Stream submitted for review', loadMyStreams);
}
async function deleteStream(id, isAdmin) {
const confirmed = await showConfirm('Delete this stream?');
if (!confirmed) return;
try {
const resp = await fetch(`/api/streams/${id}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json();
showToast(err.error || 'Failed to delete', 'error');
return;
}
showToast('Stream deleted', 'success');
if (isAdmin) {
loadAdminStreams();
} else {
loadMyStreams();
}
loadPublicStreams();
} catch (e) {
showToast('Failed to delete stream', 'error');
}
}
async function togglePublish(id) {
try {
const resp = await fetch(`/api/streams/${id}/publish`, { method: 'PUT' });
if (!resp.ok) {
showToast('Failed to toggle publish', 'error');
return;
}
showToast('Stream updated', 'success');
loadAdminStreams();
loadPublicStreams();
} catch (e) {
showToast('Failed to toggle publish', 'error');
}
}
async function refreshRedditLinks() {
try {
const resp = await fetch('/api/scraped/refresh', { method: 'POST' });
if (!resp.ok) {
showToast('Failed to trigger refresh', 'error');
return;
}
showToast('Refreshing links from Reddit...', 'info');
let attempts = 0;
const maxAttempts = 15;
const poll = setInterval(async () => {
attempts++;
await loadRedditLinks();
if (attempts >= maxAttempts) {
clearInterval(poll);
}
}, 2000);
} catch (e) {
showToast('Failed to trigger refresh', 'error');
}
}
async function triggerScrape() {
try {
await fetch('/api/admin/scrape', { method: 'POST' });
showToast('Scrape triggered', 'success');
} catch (e) {
showToast('Failed to trigger scrape', 'error');
}
}
function closeRedditViewer() {
const viewer = document.getElementById('reddit-viewer');
if (!viewer) return;
viewer.classList.add('hidden');
const contentEl = viewer.querySelector('.reddit-viewer-content');
contentEl.querySelectorAll(':scope > :not(#reddit-viewer-loader)').forEach(el => el.remove());
}
// --- Browser Session Viewer (Iframe Proxy) ---
function openBrowserSession(streamId, streamTitle, streamURL) {
const viewer = document.getElementById('browser-viewer');
const statusEl = viewer.querySelector('.browser-viewer-status');
const contentEl = viewer.querySelector('.browser-viewer-content');
const loader = document.getElementById('browser-viewer-loader');
const urlText = document.getElementById('browser-url');
const openOriginal = document.getElementById('browser-open-original');
statusEl.textContent = 'Loading...';
statusEl.classList.remove('connected');
loader.classList.remove('hidden');
// Parse the stream URL to extract origin and path
let parsed;
try {
parsed = new URL(streamURL);
} catch (e) {
statusEl.textContent = 'Invalid URL';
loader.classList.add('hidden');
showToast('Invalid stream URL', 'error');
return;
}
const origin = parsed.origin;
const pathAndSearch = parsed.pathname + parsed.search + parsed.hash;
// Base64-encode the origin (URL-safe, no padding)
const b64Origin = btoa(origin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// Build proxy URL
const proxyURL = '/proxy/' + b64Origin + pathAndSearch;
if (urlText) urlText.textContent = streamURL;
if (openOriginal) openOriginal.href = streamURL;
// Hide all tab content sections and show the viewer
document.querySelectorAll('.tab-content').forEach(s => s.classList.remove('active'));
viewer.classList.remove('hidden');
viewer.classList.add('active');
// Remove any existing iframe
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
// Create iframe
const iframe = document.createElement('iframe');
iframe.src = proxyURL;
iframe.className = 'browser-iframe';
iframe.setAttribute('allowfullscreen', '');
iframe.onload = function() {
loader.classList.add('hidden');
statusEl.textContent = 'Connected';
statusEl.classList.add('connected');
};
contentEl.appendChild(iframe);
}
function closeBrowserSession() {
const viewer = document.getElementById('browser-viewer');
viewer.classList.add('hidden');
viewer.classList.remove('active');
const contentEl = viewer.querySelector('.browser-viewer-content');
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
const statusEl = viewer.querySelector('.browser-viewer-status');
statusEl.textContent = '';
statusEl.classList.remove('connected');
const urlText = document.getElementById('browser-url');
if (urlText) urlText.textContent = '';
// Restore the previously active tab
const activeTab = document.querySelector('.tab-btn.active');
if (activeTab) {
const tabName = activeTab.dataset.tab;
const content = document.getElementById('content-' + tabName);
if (content) content.classList.add('active');
}
}

View file

@ -0,0 +1,9 @@
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View file

@ -1,11 +1,14 @@
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "turn_secret" { type = string }
variable "public_ip" { type = string }
resource "kubernetes_namespace" "f1-stream" {
metadata {
name = "f1-stream"
labels = {
"istio-injection" : "disabled"
tier = var.tier
}
}
}
@ -34,16 +37,16 @@ resource "kubernetes_deployment" "f1-stream" {
}
spec {
container {
image = "viktorbarzin/f1-stream:v1.0.0"
image = "viktorbarzin/f1-stream:v1.2.3"
name = "f1-stream"
resources {
limits = {
cpu = "0.5"
cpu = "1"
memory = "512Mi"
}
requests = {
cpu = "250m"
memory = "512Mi"
cpu = "50m"
memory = "128Mi"
}
}
port {
@ -65,6 +68,18 @@ resource "kubernetes_deployment" "f1-stream" {
name = "HEADLESS_EXTRACT_ENABLED"
value = "true"
}
env {
name = "TURN_URL"
value = "turn:${var.public_ip}:3478"
}
env {
name = "TURN_SHARED_SECRET"
value = var.turn_secret
}
env {
name = "TURN_INTERNAL_URL"
value = "turn:coturn.coturn.svc.cluster.local:3478"
}
volume_mount {
name = "data"
mount_path = "/data"