[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:
parent
f7710b6067
commit
450dfc28e4
34 changed files with 6223 additions and 7 deletions
3
modules/kubernetes/f1-stream/files/.dockerignore
Normal file
3
modules/kubernetes/f1-stream/files/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
.claude/
|
||||
.git/
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
45
modules/kubernetes/f1-stream/files/go.mod
Normal file
45
modules/kubernetes/f1-stream/files/go.mod
Normal 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
|
||||
)
|
||||
89
modules/kubernetes/f1-stream/files/go.sum
Normal file
89
modules/kubernetes/f1-stream/files/go.sum
Normal 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=
|
||||
359
modules/kubernetes/f1-stream/files/internal/auth/auth.go
Normal file
359
modules/kubernetes/f1-stream/files/internal/auth/auth.go
Normal 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
|
||||
}
|
||||
20
modules/kubernetes/f1-stream/files/internal/auth/context.go
Normal file
20
modules/kubernetes/f1-stream/files/internal/auth/context.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
167
modules/kubernetes/f1-stream/files/internal/extractor/capture.go
Normal file
167
modules/kubernetes/f1-stream/files/internal/extractor/capture.go
Normal 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)
|
||||
}
|
||||
383
modules/kubernetes/f1-stream/files/internal/extractor/session.go
Normal file
383
modules/kubernetes/f1-stream/files/internal/extractor/session.go
Normal 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)
|
||||
}
|
||||
248
modules/kubernetes/f1-stream/files/internal/extractor/webrtc.go
Normal file
248
modules/kubernetes/f1-stream/files/internal/extractor/webrtc.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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] + "..."
|
||||
}
|
||||
53
modules/kubernetes/f1-stream/files/internal/models/models.go
Normal file
53
modules/kubernetes/f1-stream/files/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
429
modules/kubernetes/f1-stream/files/internal/proxy/proxy.go
Normal file
429
modules/kubernetes/f1-stream/files/internal/proxy/proxy.go
Normal 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
|
||||
}
|
||||
327
modules/kubernetes/f1-stream/files/internal/scraper/reddit.go
Normal file
327
modules/kubernetes/f1-stream/files/internal/scraper/reddit.go
Normal 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] + "..."
|
||||
}
|
||||
105
modules/kubernetes/f1-stream/files/internal/scraper/scraper.go
Normal file
105
modules/kubernetes/f1-stream/files/internal/scraper/scraper.go
Normal 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))
|
||||
}
|
||||
142
modules/kubernetes/f1-stream/files/internal/scraper/validate.go
Normal file
142
modules/kubernetes/f1-stream/files/internal/scraper/validate.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
293
modules/kubernetes/f1-stream/files/internal/server/server.go
Normal file
293
modules/kubernetes/f1-stream/files/internal/server/server.go
Normal 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)
|
||||
}
|
||||
37
modules/kubernetes/f1-stream/files/internal/store/health.go
Normal file
37
modules/kubernetes/f1-stream/files/internal/store/health.go
Normal 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
|
||||
}
|
||||
63
modules/kubernetes/f1-stream/files/internal/store/scraped.go
Normal file
63
modules/kubernetes/f1-stream/files/internal/store/scraped.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
53
modules/kubernetes/f1-stream/files/internal/store/store.go
Normal file
53
modules/kubernetes/f1-stream/files/internal/store/store.go
Normal 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)
|
||||
}
|
||||
176
modules/kubernetes/f1-stream/files/internal/store/streams.go
Normal file
176
modules/kubernetes/f1-stream/files/internal/store/streams.go
Normal 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
|
||||
}
|
||||
91
modules/kubernetes/f1-stream/files/internal/store/users.go
Normal file
91
modules/kubernetes/f1-stream/files/internal/store/users.go
Normal 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
|
||||
}
|
||||
161
modules/kubernetes/f1-stream/files/main.go
Normal file
161
modules/kubernetes/f1-stream/files/main.go
Normal 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
|
||||
}
|
||||
1447
modules/kubernetes/f1-stream/files/static/css/custom.css
Normal file
1447
modules/kubernetes/f1-stream/files/static/css/custom.css
Normal file
File diff suppressed because it is too large
Load diff
4
modules/kubernetes/f1-stream/files/static/css/pico.min.css
vendored
Normal file
4
modules/kubernetes/f1-stream/files/static/css/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
202
modules/kubernetes/f1-stream/files/static/index.html
Normal file
202
modules/kubernetes/f1-stream/files/static/index.html
Normal 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">🏁</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">📡</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">🎮</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">×</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">×</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()">×</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>
|
||||
121
modules/kubernetes/f1-stream/files/static/js/app.js
Normal file
121
modules/kubernetes/f1-stream/files/static/js/app.js
Normal 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)">×</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();
|
||||
}
|
||||
}
|
||||
});
|
||||
219
modules/kubernetes/f1-stream/files/static/js/auth.js
Normal file
219
modules/kubernetes/f1-stream/files/static/js/auth.js
Normal 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
|
||||
}
|
||||
}
|
||||
397
modules/kubernetes/f1-stream/files/static/js/streams.js
Normal file
397
modules/kubernetes/f1-stream/files/static/js/streams.js
Normal 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">📋</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');
|
||||
}
|
||||
}
|
||||
9
modules/kubernetes/f1-stream/files/static/js/utils.js
Normal file
9
modules/kubernetes/f1-stream/files/static/js/utils.js
Normal 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, '&').replace(/'/g, ''').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue