diff --git a/.env b/.env new file mode 100644 index 00000000..20e3d98e --- /dev/null +++ b/.env @@ -0,0 +1,43 @@ +# ################ +# SERVER +# ################ + +# HTTP_ADDRESS=:8080 +# HTTPS_REDIRECT_PORT=80 +# HTTP_ENABLE_REDIRECT=TRUE +# NETWORK_TEST_ON_START=TRUE +# INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP=TRUE + +# ################ +# SSL +# ################ + +# USE_SSL=TRUE +# SSL_KEY=./certs/key.pem +# SSL_CERT=./certs/cert.pem + +# ################ +# AUTHORIZATION +# ################ + +# LOCAL STREAM PROFILE +# STREAM_PROFILE_ACTIVE=TRUE +# STREAM_PROFILE_PATH=./profiles + +# WEBHOOK AUTHORIZATION +# WEBHOOK_URL=http://your-server + +# ################ +# FRONTEND +# ################ + +# DISABLE_FRONTEND=TRUE +# FRONTEND_PATH="./web/build" + +# ################ +# DEBUGGING +# ################ + +# DEBUG_INCOMING_API_REQUEST=TRUE +# DEBUG_PRINT_ANSWER=TRUE +# DEBUG_PRINT_OFFER=TRUE diff --git a/.env.development b/.env.development index ed942892..ae4a7978 100644 --- a/.env.development +++ b/.env.development @@ -1,9 +1,64 @@ -HTTP_ADDRESS=":8080" -ENABLE_HTTP_REDIRECT= -VITE_API_PATH="http://localhost:8080/api" +# ################ +# SERVER +# ################ -# /etc/letsencrypt/live//privkey.pem -SSL_KEY= +# HTTP_ADDRESS=:8080 +# HTTPS_REDIRECT_PORT=80 +# HTTP_ENABLE_REDIRECT=TRUE +# NETWORK_TEST_ON_START=FALSE +# INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP=TRUE -# /etc/letsencrypt/live//fullchain.pem -SSL_CERT= +# ################ +# SSL +# ################ + +# USE_SSL=TRUE +# SSL_KEY=./certs/key.pem +# SSL_CERT=./certs/cert.pem + +# ################ +# AUTHORIZATION +# ################ + +# LOCAL STREAM PROFILE +# STREAM_PROFILE_ACTIVE=TRUE +# STREAM_PROFILE_PATH=./profiles + +# WEBHOOK AUTHORIZATION +# WEBHOOK_URL=http://your-server + +# ################ +# FRONTEND +# ################ + +# DISABLE_FRONTEND=TRUE +# FRONTEND_PATH="./web/build" + +# ################ +# TURN/STUN +# ################ + +# STUN_SERVERS="192.168.1.101:3478|192.168.1.101:3478" +# TURN_SERVERS="192.168.1.123:3478|192.168.1.321:3478" +# TURN_SERVERS_INTERNAL="10.100.0.10:3478" +# STUN_SERVERS_INTERNAL="10.100.0.10:3478" +# TURN_SERVER_AUTH_SECRET="YouSecret" + +# ################ +# DEBUGGING +# ################ + +# DEBUG_INCOMING_API_REQUEST=TRUE +# DEBUG_PRINT_ANSWER=TRUE +# DEBUG_PRINT_OFFER=TRUE +# DEBUG_PRINT_SSE_MESSAGES=TRUE + +# ################ +# LOGGING +# ################ +# LOGGING_ENABLED=TRUE +# LOGGING_DIRECTORY=logs +# LOGGING_SINGLEFILE=FALSE +# LOGGING_NEW_FILE_ON_STARTUP=FALSE +# LOGGING_API_ENABLED=TRUE +# LOGGING_API_KEY=YourApiKey diff --git a/.env.production b/.env.production index 02e08b60..20e3d98e 100644 --- a/.env.production +++ b/.env.production @@ -1,9 +1,43 @@ -HTTP_ADDRESS=":8080" -ENABLE_HTTP_REDIRECT= -VITE_API_PATH="/api" +# ################ +# SERVER +# ################ -# /etc/letsencrypt/live//privkey.pem -SSL_KEY= +# HTTP_ADDRESS=:8080 +# HTTPS_REDIRECT_PORT=80 +# HTTP_ENABLE_REDIRECT=TRUE +# NETWORK_TEST_ON_START=TRUE +# INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP=TRUE -# /etc/letsencrypt/live//fullchain.pem -SSL_CERT= +# ################ +# SSL +# ################ + +# USE_SSL=TRUE +# SSL_KEY=./certs/key.pem +# SSL_CERT=./certs/cert.pem + +# ################ +# AUTHORIZATION +# ################ + +# LOCAL STREAM PROFILE +# STREAM_PROFILE_ACTIVE=TRUE +# STREAM_PROFILE_PATH=./profiles + +# WEBHOOK AUTHORIZATION +# WEBHOOK_URL=http://your-server + +# ################ +# FRONTEND +# ################ + +# DISABLE_FRONTEND=TRUE +# FRONTEND_PATH="./web/build" + +# ################ +# DEBUGGING +# ################ + +# DEBUG_INCOMING_API_REQUEST=TRUE +# DEBUG_PRINT_ANSWER=TRUE +# DEBUG_PRINT_OFFER=TRUE diff --git a/.gitignore b/.gitignore index 74ab1a85..1bcce424 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,15 @@ yarn-error.log* # media files *.ogg *.h264 + +# build files +BroadcastBox.exe + +# log files +/logs + +# profile files +/profiles + +# Go vendors +/vendor diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..557867b4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +linters: + enable: + - unused + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/GoRun.ps1 b/GoRun.ps1 new file mode 100644 index 00000000..ad543dea --- /dev/null +++ b/GoRun.ps1 @@ -0,0 +1,4 @@ +go build -o BroadcastBox.exe +if ($LASTEXITCODE -eq 0) { + .\BroadcastBox.exe $args +} diff --git a/README.md b/README.md index f9ec2699..e6b60931 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ - [Docker](#docker) - [Docker Compose](#docker-compose) - [Environment variables](#environment-variables) - - [Authentication and Logging](#authentication-and-logging) + - [Webhook - Authentication and Logging](#webhook---authentication-and-logging) - [Network Test on Start](#network-test-on-start) - [Design](#design) -## What is Broadcast Box +## What is Broadcast Box Broadcast Box lets you broadcast to others in sub-second time. It was designed to be simple to use and easily modifiable. We wrote Broadcast Box to show off some @@ -197,6 +197,7 @@ will be automatically updated every night. If you are running on a VPS/Cloud ser export URL=my-server.com docker-compose up -d ``` + ## URL Parameters The frontend can be configured by passing these URL Parameters. @@ -205,43 +206,105 @@ The frontend can be configured by passing these URL Parameters. ## Environment Variables -The backend can be configured with the following environment variables. - -- `WEBHOOK_URL` - URL for Webhook Backend. Provides authentication and logging -- `DISABLE_STATUS` - Disable the status API -- `DISABLE_FRONTEND` - Disable the serving of frontend. Only REST APIs + WebRTC is enabled. -- `HTTP_ADDRESS` - HTTP Server Address -- `NETWORK_TEST_ON_START` - When "true" on startup Broadcast Box will check network connectivity - -- `ENABLE_HTTP_REDIRECT` - HTTP traffic will be redirect to HTTPS -- `SSL_CERT` - Path to SSL certificate if using Broadcast Box's HTTP Server -- `SSL_KEY` - Path to SSL key if using Broadcast Box's HTTP Server - -- `NAT_1_TO_1_IP` - Announce IPs that don't belong to local machine (like Public IP). delineated by '|' -- `INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP` - Like `NAT_1_TO_1_IP` but autoconfigured -- `INTERFACE_FILTER` - Only use a certain interface for UDP traffic -- `NAT_ICE_CANDIDATE_TYPE` - By default setting a NAT_1_TO_1_IP overrides. Set this to `srflx` to instead append IPs -- `STUN_SERVERS` - List of STUN servers delineated by '|'. Useful if Broadcast Box is running behind a NAT -- `NETWORK_TYPES` - List of network types to use, delineated by '|'. Default is `udp4|udp6`. -- `INCLUDE_LOOPBACK_CANDIDATE` - Also listen for WebRTC traffic on loopback, disabled by default - -- `UDP_MUX_PORT_WHEP` - Like `UDP_MUX_PORT` but only for WHEP traffic -- `UDP_MUX_PORT_WHIP` - Like `UDP_MUX_PORT` but only for WHIP traffic -- `UDP_MUX_PORT` - Serve all UDP traffic via one port. By default Broadcast Box listens on a random port - -- `TCP_MUX_ADDRESS` - If you wish to make WebRTC traffic available via TCP. -- `TCP_MUX_FORCE` - If you wish to make WebRTC traffic only available via TCP. - -- `APPEND_CANDIDATE` - Append candidates to Offer that ICE Agent did not generate. Worse version of `NAT_1_TO_1_IP` - -- `DEBUG_PRINT_OFFER` - Print WebRTC Offers from client to Broadcast Box. Debug things like accepted codecs. -- `DEBUG_PRINT_ANSWER` - Print WebRTC Answers from Broadcast Box to Browser. Debug things like IP/Ports returned to client. - -## Authentication and Logging +### Server Configuration + +| Variable | Description | +| ----------------------- | -------------------------------------------------------- | +| `HTTP_ADDRESS` | Address for the HTTP server to bind to. | +| `ENABLE_HTTP_REDIRECT` | Enables automatic redirection from HTTP to HTTPS. | +| `HTTPS_REDIRECT_PORT` | Port to redirect HTTP traffic to HTTPS when using HTTPS. | +| `NETWORK_TEST_ON_START` | If "true", checks network connectivity on startup. | +| `DISABLE_STATUS` | Disables the status API endpoint. | +| `ENABLE_PROFILING` | Enables PPROF profiling on localhost:6060 | + +### SSL Configuration + +| Variable | Description | +| ---------- | --------------------------------- | +| `USE_SSL` | Setup the server to run with SSL. | +| `SSL_CERT` | Path to the SSL certificate file. | +| `SSL_KEY` | Path to the SSL key file. | + +### Authorization & Profiles + +| Variable | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `STREAM_PROFILE_PATH` | Path to store stream profile configurations. | +| `STREAM_PROFILE_POLICY` | Policy configuration for stream profiles. Default is 'Anyone' See [Stream Profile Policy](#stream-profile-policy). | +| `WEBHOOK_URL` | URL for webhook backend used for authentication and logging. see [Webhook - Authentication and Logging](#webhook---authentication-and-logging). | + +### Frontend Configuration + +| Variable | Description | +| ---------------------- | -------------------------------- | +| `DISABLE_FRONTEND` | Disables frontend serving. | +| `FRONTEND_PATH` | Path to frontend assets. | +| `FRONTEND_ADMIN_TOKEN` | Admin token for frontend access. | + +### WebRTC & Networking + +| Variable | Description | +| ------------------------------------ | ------------------------------------------------------------------------ | +| `INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP` | Automatically includes public IPs in NAT configuration. | +| `NAT_1_TO_1_IP` | Manually specify IPs (like Public IP) to announce, delineated by `\|` | +| `INTERFACE_FILTER` | Restrict UDP traffic to a specific network interface. | +| `NAT_ICE_CANDIDATE_TYPE` | Set to `srflx` to append IPs instead of overriding with `NAT_1_TO_1_IP`. | +| `NETWORK_TYPES` | List of network types to use delineated by `\|` (e.g.,`udp4 \|udp6`). | +| `INCLUDE_LOOPBACK_CANDIDATE` | Enables WebRTC traffic on loopback interface. | +| `UDP_MUX_PORT` | Port to multiplex all UDP traffic. Uses random port by default. | +| `UDP_MUX_PORT_WHEP` | Port to multiplex WHEP traffic only. | +| `UDP_MUX_PORT_WHIP` | Port to multiplex WHIP traffic only. | +| `TCP_MUX_ADDRESS` | Address to serve WebRTC traffic over TCP. | +| `TCP_MUX_FORCE` | Forces WebRTC traffic to use TCP only. | +| `APPEND_CANDIDATE` | Appends ICE candidates not generated by the agent. | +| `WHEP_SESSION_AUDIOCHANNEL_SIZE` | Tunes the AudioChannel size for WHEP sessions. | +| `WHEP_SESSION_VIDEOCHANNEL_SIZE` | Tunes the VideoChannel size for WHEP sessions. | + +### STUN/TURN Servers + +| Variable | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `STUN_SERVERS` | List of public STUN servers separated by `\|`. | +| `STUN_SERVERS_INTERNAL` | List of internal STUN servers used by the backend in case it has trouble connecting to the public STUN server. Separated by `\|`. | +| `TURN_SERVERS` | List of public TURN servers separated by `\|`. | +| `TURN_SERVERS_INTERNAL` | List of internal TURN servers used by the backend in case it has trouble connecting to the public TURN server. Separated by `\|`. | +| `TURN_SERVER_AUTH_SECRET` | Shared secret for TURN server authentication. | + +### Debugging + +| Variable | Description | +| ---------------------------- | ------------------------------------------- | +| `DEBUG_PRINT_OFFER` | Prints WebRTC offers received from clients. | +| `DEBUG_PRINT_ANSWER` | Prints WebRTC answers sent to clients. | +| `DEBUG_INCOMING_API_REQUEST` | Logs incoming API request paths. | +| `DEBUG_PRINT_SSE_MESSAGES` | Logs Server-Sent Events messages. | + +### Logging + +| Variable | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| `LOGGING_ENABLED` | Enables logging system. | +| `LOGGING_DIRECTORY` | Directory to store log files. | +| `LOGGING_SINGLEFILE` | Logs everything into a single file called 'log'. Default is log files are stamped with current date. | +| `LOGGING_NEW_FILE_ON_STARTUP` | Creates a new log file on each startup. Either a new 'log' file, or replaces the current dates log file. | +| `LOGGING_API_ENABLED` | Enables logging API to show current log entries on the backend. `/api/log` | +| `LOGGING_API_KEY` | When set, the logging API requires a bearer token that uses this key. | + +## Stream Profile Policy + +The `STREAM_PROFILE_POLICY` environment variable controls who is allowed to initiate streaming sessions based on profile reservation status. + +| Value | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `ANYONE_WITH_RESERVED` | If Stream keys are reserved in advance, only a valid token can be used with them. If not reserved, anyone can used the streamkey | +| `RESERVED` | Only users with a valid token **and** a reserved stream key are allowed to stream. This is the most restrictive mode. | + +## Webhook - Authentication and Logging To prevent random users from streaming to your server, you can set the `WEBHOOK_URL` and validate/process requests in your code. This enables you to separate the authorization between broadcasting (whip) and watching (whep). So you can safely share a watch link without exposing the key used for broadcasting. -If the request succeeds (meaning the stream key is accepted), broadcast-box redirects the stream to an url given by the external server, otherwise the streaming request is dropped. +If the request succeeds (meaning the stream key is accepted), broadcast-box redirects the stream to an url given +by the external server, otherwise the streaming request is dropped. See [here](examples/webhook-server.go). For an example Webhook Server that only allows the stream `broadcastBoxRulez` @@ -285,11 +348,14 @@ If you wish to disable the test set the environment variable `NETWORK_TEST_ON_ST ## Design -The backend exposes three endpoints (the status page is optional, if hosting locally). +The backend exposes the following endpoints to support WebRTC streaming and server-side monitoring: -- `/api/whip` - Start a WHIP Session. WHIP broadcasts video via WebRTC. -- `/api/whep` - Start a WHEP Session. WHEP is video playback via WebRTC. -- `/api/status` - Status of the all active WHIP streams +| Endpoint | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------------- | +| `/api/whip` | Initiates a WHIP session for broadcasting video via WebRTC. Requires the Authorization header with a bearer token | +| `/api/whep` | Initiates a WHEP session for video playback via WebRTC. | +| `/api/status` | Returns the status of all active WHIP streams. If a Stream Profile is not public, it will not be included. | +| `/api/log` | Retrieves current server logs. Useful for debugging and monitoring runtime activity. | [license-image]: https://img.shields.io/badge/License-MIT-yellow.svg [license-url]: https://opensource.org/licenses/MIT diff --git a/go.mod b/go.mod index 1d1a32af..510f39bb 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/glimesh/broadcast-box -go 1.23.0 - -toolchain go1.24.0 +go 1.24.3 require ( github.com/google/uuid v1.6.0 @@ -12,27 +10,30 @@ require ( github.com/pion/interceptor v0.1.43 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.0 - github.com/pion/sdp/v3 v3.0.17 github.com/pion/webrtc/v4 v4.2.3 github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/time v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( github.com/pion/datachannel v1.6.0 // 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/sctp v1.9.2 // indirect + github.com/pion/sdp/v3 v3.0.17 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/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/time v0.10.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 0ce40acd..c51fb219 100644 --- a/go.sum +++ b/go.sum @@ -48,14 +48,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu 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= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/console/flags.go b/internal/console/flags.go new file mode 100644 index 00000000..5ef498b4 --- /dev/null +++ b/internal/console/flags.go @@ -0,0 +1,7 @@ +package console + +const ( + // Create new profile + createNewProfile = "createNewProfile" + createNewProfile_StreamKey = "streamKey" +) diff --git a/internal/console/handler.go b/internal/console/handler.go new file mode 100644 index 00000000..3d34d33f --- /dev/null +++ b/internal/console/handler.go @@ -0,0 +1,32 @@ +package console + +import ( + "flag" + "log" + "os" + + "github.com/glimesh/broadcast-box/internal/server/authorization" +) + +func HandleConsoleFlags() { + createNewProfile := flag.Bool(createNewProfile, false, "Create a new stream profile from the -streamKey flag") + streamKey := flag.String(createNewProfile_StreamKey, "", "The stream key used to identify a streaming session") + + flag.Parse() + + if *createNewProfile { + if len(*streamKey) == 0 { + log.Println("No stream key was provided. Use the flags `-createNewProfile -streamKey MyStreamKey` to create a new profile.") + os.Exit(0) + } + + token, err := authorization.CreateProfile(*streamKey) + if err != nil { + log.Println(err) + os.Exit(0) + } + + log.Println("Created", *streamKey, "with bearer token:", token) + os.Exit(0) + } +} diff --git a/internal/environment/environment.go b/internal/environment/environment.go new file mode 100644 index 00000000..369c2d03 --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,60 @@ +package environment + +import ( + "log" + "os" + "path/filepath" + + "github.com/joho/godotenv" +) + +func LoadEnvironmentVariables() { + files := []string{ + ".env.development", + ".env.production", + } + + // Load base environment file if available + loadEnvironmentFile(".env") + + for _, file := range files { + loadEnvironmentFile(file) + setDefaultEnvironmentVariables() + return + } + + log.Println("Environment: Could not find any environment files") + os.Exit(0) +} + +func loadEnvironmentFile(filePath string) { + currentWorkingDirectory, err := os.Getwd() + if err != nil { + log.Fatal("Environment:", err) + } + + path := filepath.Join(currentWorkingDirectory, filePath) + + if _, err := os.Stat(path); err == nil { + err := godotenv.Overload(path) + + if err != nil { + log.Println("Environment: Error occurred loading environment file", path) + log.Println(err) + + os.Exit(0) + } + + log.Println("Environment: Loaded", filePath) + } +} + +func setDefaultEnvironmentVariables() { + if os.Getenv(STREAM_PROFILE_PATH) == "" { + log.Println("Environment: Setting STREAM_PROFILE_PATH: profiles") + err := os.Setenv(STREAM_PROFILE_PATH, "profiles") + if err != nil { + log.Panic("Error setting default value for STREAM_PROFILE_PATH") + } + } +} diff --git a/internal/environment/logger.go b/internal/environment/logger.go new file mode 100644 index 00000000..397bf493 --- /dev/null +++ b/internal/environment/logger.go @@ -0,0 +1,140 @@ +package environment + +import ( + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +var ( + currentDate string + logMutex sync.Mutex +) + +func SetupLogger() { + if strings.EqualFold(os.Getenv(LOGGING_ENABLED), "false") { + return + } + + startLogRotation() +} + +func setupLoggerForDate(date string) { + logFile, err := getLogFileWriter() + if err != nil { + log.Printf("Failed to open log file: %v", err) + return + } + + multiWriter := io.MultiWriter(os.Stdout, logFile) + log.SetOutput(multiWriter) + currentDate = date +} + +func startLogRotation() { + go func() { + for { + now := time.Now().Format("20060102") + logMutex.Lock() + if now != currentDate { + setupLoggerForDate(now) + } + logMutex.Unlock() + time.Sleep(1 * time.Minute) + } + }() +} + +func GetLogFileReader() (logFile *os.File, err error) { + logDir, _, _ := getLogfilePath() + logFilePath, err := getLatestLogFile(logDir) + if err != nil { + log.Println("Logger Error:", err) + } + + file, err := os.Open(logFilePath) + if err != nil { + log.Println("Logger Error:", err) + } + + return file, err +} + +func getLogFileWriter() (logFile *os.File, err error) { + logDir, _, logFilePath := getLogfilePath() + + if err := os.MkdirAll(logDir, os.ModePerm); err != nil { + log.Fatalf("Failed to create log directory: %v", err) + } + + if envLogTruncateExistingFile := strings.EqualFold(os.Getenv(LOGGING_NEW_FILE_ON_STARTUP), "true"); envLogTruncateExistingFile { + logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666) + } else { + logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + } + + if err != nil { + log.Println("Logger Error: FilePath", logFilePath) + log.Fatalf("Logger Error: %v", err) + return nil, err + } + + return logFile, nil +} + +func getLogfilePath() (directory string, fileName string, logFilePath string) { + logDir := "logs" + if envLogDir := os.Getenv(LOGGING_DIRECTORY); envLogDir != "" { + logDir = envLogDir + } + + logFileName := time.Now().Format("20060102") + + if envLogFileIsSingleFile := strings.EqualFold(os.Getenv(LOGGING_SINGLEFILE), "true"); envLogFileIsSingleFile { + logFileName = "log" + } + + return logDir, logFileName, logDir + "/" + logFileName +} + +func getLatestLogFile(logDir string) (string, error) { + var dates []time.Time + var fileMap = make(map[time.Time]string) + + err := filepath.WalkDir(logDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.Contains(d.Name(), ".") { + return nil + } + + t, err := time.Parse("20060102", d.Name()) + if err != nil { + return nil + } + + dates = append(dates, t) + fileMap[t] = path + return nil + }) + + if err != nil { + return "", err + } + + if len(dates) == 0 { + return "", fmt.Errorf("no log files found") + } + + sort.Slice(dates, func(i, j int) bool { + return dates[i].After(dates[j]) + }) + + latest := dates[0] + return fileMap[latest], nil +} diff --git a/internal/environment/variables.go b/internal/environment/variables.go new file mode 100644 index 00000000..7765b6da --- /dev/null +++ b/internal/environment/variables.go @@ -0,0 +1,69 @@ +package environment + +const ( + // SERVER + HTTP_ADDRESS = "HTTP_ADDRESS" + HTTPS_REDIRECT_PORT = "HTTPS_REDIRECT_PORT" + HTTP_ENABLE_REDIRECT = "ENABLE_HTTP_REDIRECT" + NETWORK_TEST_ON_START = "NETWORK_TEST_ON_START" + INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP = "INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP" + DISABLE_STATUS = "DISABLE_STATUS" + ENABLE_PROFILING = "ENABLE_PROFILING" + + // SSL + USE_SSL = "USE_SSL" + SSL_KEY = "SSL_KEY" + SSL_CERT = "SSL_CERT" + + // AUTHORIZATION + STREAM_PROFILE_PATH = "STREAM_PROFILE_PATH" + STREAM_PROFILE_POLICY = "STREAM_PROFILE_POLICY" + WEBHOOK_URL = "WEBHOOK_URL" + + // FRONTEND + FRONTEND_DISABLED = "DISABLE_FRONTEND" + FRONTEND_PATH = "FRONTEND_PATH" + FRONTEND_ADMIN_TOKEN = "FRONTEND_ADMIN_TOKEN" + + // WEBRTC + INCLUDE_LOOPBACK_CANDIDATE = "INCLUDE_LOOPBACK_CANDIDATE" + NETWORK_TYPES = "NETWORK_TYPES" + TCP_MUX_FORCE = "TCP_MUX_FORCE" + TCP_MUX_ADDRESS = "TCP_MUX_ADDRESS" + INTERFACE_FILTER = "INTERFACE_FILTER" + UDP_MUX_PORT = "UDP_MUX_PORT" + UDP_MUX_PORT_WHIP = "UDP_MUX_PORT_WHIP" + UDP_MUX_PORT_WHEP = "UDP_MUX_PORT_WHEP" + NAT_1_TO_1_IP = "NAT_1_TO_1_IP" + NAT_ICE_CANDIDATE_TYPE = "NAT_ICE_CANDIDATE_TYPE" + + // TURN/STUN + STUN_SERVERS = "STUN_SERVERS" + TURN_SERVERS = "TURN_SERVERS" + TURN_SERVERS_INTERNAL = "TURN_SERVERS_INTERNAL" + STUN_SERVERS_INTERNAL = "STUN_SERVERS_INTERNAL" + TURN_SERVER_AUTH_SECRET = "TURN_SERVER_AUTH_SECRET" + + // PEERCONNECTION + APPEND_CANDIDATE = "APPEND_CANDIDATE" + + // DEBUGGING + DEBUG_INCOMING_API_REQUEST = "DEBUG_INCOMING_API_REQUEST" + DEBUG_PRINT_ANSWER = "DEBUG_PRINT_ANSWER" + DEBUG_PRINT_OFFER = "DEBUG_PRINT_OFFER" + DEBUG_PRINT_SSE_MESSAGES = "DEBUG_PRINT_SSE_MESSAGES" + + // LOGGING + LOGGING_ENABLED = "LOGGING_ENABLED" + LOGGING_DIRECTORY = "LOGGING_DIRECTORY" + LOGGING_SINGLEFILE = "LOGGING_SINGLEFILE" + LOGGING_NEW_FILE_ON_STARTUP = "LOGGING_NEW_FILE_ON_STARTUP" + LOGGING_API_ENABLED = "LOGGING_API_ENABLED" + LOGGING_API_KEY = "LOGGING_API_KEY" + + // WHEP SESSION + WHEP_SESSION_AUDIOCHANNEL_SIZE = "WHEP_SESSION_AUDIOCHANNEL_SIZE" + WHEP_SESSION_VIDEOCHANNEL_SIZE = "WHEP_SESSION_VIDEOCHANNEL_SIZE" + WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS = "WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS" + WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS_TO_CHANNEL = "WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS_TO_CHANNEL" +) diff --git a/internal/ip/ip.go b/internal/ip/ip.go new file mode 100644 index 00000000..f7b654fd --- /dev/null +++ b/internal/ip/ip.go @@ -0,0 +1,41 @@ +package ip + +import ( + "encoding/json" + "io" + "log" + "net/http" +) + +func GetPublicIp() string { + req, err := http.Get("http://ip-api.com/json/") + + if err != nil { + log.Fatal(err) + } + + defer func() { + if closeErr := req.Body.Close(); closeErr != nil { + log.Fatal(err) + } + }() + + body, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + + ip := struct { + Query string + }{} + + if err = json.Unmarshal(body, &ip); err != nil { + log.Fatal(err) + } + + if ip.Query == "" { + log.Fatal("Query entry was not populated") + } + + return ip.Query +} diff --git a/internal/server/authorization/helpers.go b/internal/server/authorization/helpers.go new file mode 100644 index 00000000..cdfb0761 --- /dev/null +++ b/internal/server/authorization/helpers.go @@ -0,0 +1,114 @@ +package authorization + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/google/uuid" +) + +func assureProfilePath() { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + + err := os.MkdirAll(profilePath, os.ModePerm) + if err != nil { + log.Println("Authorization: Error creating profile path folder folder:", err) + return + } +} + +func IsValidStreamBearerToken(bearerToken string) bool { + return hasExistingBearerToken(bearerToken) +} + +func hasExistingStreamKey(streamKey string) bool { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + files, err := os.ReadDir(profilePath) + + if err != nil { + log.Println("Authorization: Error reading profile directory", err) + return false + } + + filePrefix := streamKey + "_" + for _, file := range files { + if !file.IsDir() && strings.HasPrefix(file.Name(), filePrefix) { + return true + } + } + + return false +} + +func hasExistingBearerToken(bearerToken string) bool { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + + files, err := os.ReadDir(profilePath) + if err != nil { + log.Println("Authorization: Error reading profile directory", err) + return false + } + + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), bearerToken) { + return true + } + } + + return false +} + +func getProfileFileNameByStreamKey(streamKey string) (string, error) { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + + files, err := os.ReadDir(profilePath) + if err != nil { + log.Println("Authorization: Error reading profile directory", err) + return "", err + } + + for _, file := range files { + fileToken := strings.Split(file.Name(), "_") + + if !file.IsDir() && strings.EqualFold(streamKey, fileToken[0]) { + return file.Name(), nil + } + } + + return "", fmt.Errorf("could not find profile file") +} + +func getProfileFileNameByBearerToken(bearerToken string) (string, error) { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + + files, err := os.ReadDir(profilePath) + if err != nil { + log.Println("Authorization: Error reading profile directory", err) + return "", err + } + + separator := "_" + for _, file := range files { + splitIndex := strings.LastIndex(file.Name(), separator) + fileToken := file.Name()[splitIndex+len(separator):] + + if !file.IsDir() && strings.EqualFold(bearerToken, fileToken) { + return file.Name(), nil + } + } + + return "", fmt.Errorf("could not find profile file") +} + +func generateToken() string { + token := uuid.New().String() + + if hasExistingBearerToken(token) { + return generateToken() + } + + return token +} diff --git a/internal/server/authorization/iceConnectionHelpers.go b/internal/server/authorization/iceConnectionHelpers.go new file mode 100644 index 00000000..b175c5b1 --- /dev/null +++ b/internal/server/authorization/iceConnectionHelpers.go @@ -0,0 +1,28 @@ +package authorization + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "os" + "strconv" + "time" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +func GetTURNCredentials() (username string, credentials string) { + turnAuthKey := os.Getenv(environment.TURN_SERVER_AUTH_SECRET) + + if turnAuthKey == "" { + return "BroadcastBox", "BroadcastBox" + } + + timestamp := time.Now().Unix() + 3600 + username = strconv.FormatInt(timestamp, 10) + secret := hmac.New(sha1.New, []byte(turnAuthKey)) + secret.Write([]byte(username)) + rawPassword := secret.Sum(nil) + + return username, base64.StdEncoding.EncodeToString(rawPassword) +} diff --git a/internal/server/authorization/stream_profile.go b/internal/server/authorization/stream_profile.go new file mode 100644 index 00000000..faa66718 --- /dev/null +++ b/internal/server/authorization/stream_profile.go @@ -0,0 +1,245 @@ +package authorization + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +const ( + STREAM_POLICY_ANYONE = "ANYONE" + STREAM_POLICY_WITH_RESERVED = "ANYONE_WITH_RESERVED" + STREAM_POLICY_RESERVED_ONLY = "RESERVED" +) + +func isValidStreamKey(streamKey string) bool { + regExp := regexp.MustCompile(`[\p{L}\p{N}_-]+`) + return regExp.MatchString(streamKey) +} + +// Create a new profile for the provided streamkey +func CreateProfile(streamKey string) (string, error) { + + if !isValidStreamKey(streamKey) { + log.Println("Authorization: Create profile failed due to invalid streamkey", streamKey) + return "", fmt.Errorf("streamkey has invalid characters, only numbers, letters, dash and underscore allowed") + } + + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + assureProfilePath() + + if hasExistingStreamKey(streamKey) { + return "", fmt.Errorf("%s", "A profile with the stream key "+streamKey+" already exists") + } + + token := generateToken() + + fileName := streamKey + "_" + token + profileFilePath := filepath.Join(profilePath, fileName) + profile := Profile{ + FileName: fileName, + IsPublic: true, + MOTD: "Welcome to " + streamKey + "!", + } + + jsonData, err := json.MarshalIndent(profile, "", " ") + if err != nil { + log.Println("Authorization: Error ocurred while trying to create profile") + log.Println(err) + return "", err + } + + err = os.WriteFile(profileFilePath, jsonData, 0644) + if err != nil { + log.Println("Authorization: Error ocurred while trying to create profile") + log.Println(err) + return "", err + } + + return token, nil +} + +// Update a current profile +func UpdateProfile(token string, motd string, isPublic bool) error { + if !hasExistingBearerToken(token) { + return fmt.Errorf("Profile was not found") + } + + profile, err := GetPersonalProfile(token) + if err != nil { + log.Println("Authorization: Could not find personal profile") + log.Println(err) + return err + } + + // Update properties + profile.MOTD = motd + profile.IsPublic = isPublic + + jsonData, err := json.MarshalIndent(profile, "", " ") + if err != nil { + log.Println("Authorization: Error ocurred while trying to update profile") + log.Println(err) + return err + } + + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + profileFilePath, err := getProfileFileNameByBearerToken(token) + if err != nil { + log.Println("Authorization: Error ocurred while trying to update profile") + log.Println(err) + return err + } + + log.Println("Authorization: Updated Profile", profile.StreamKey) + err = os.WriteFile(filepath.Join(profilePath, profileFilePath), jsonData, 0644) + if err != nil { + log.Println("Authorization: Error ocurred while trying to update profile") + log.Println(err) + return err + } + + return nil +} + +func RemoveProfile(streamKey string) (bool, error) { + if !isValidStreamKey(streamKey) { + log.Println("Authorization: Remove profile failed due to invalid streamkey", streamKey) + return false, fmt.Errorf("streamkey has invalid characters, only numbers, letters, dash and underscore allowed") + } + + fileName, _ := getProfileFileNameByStreamKey(streamKey) + if fileName == "" { + log.Println("Authorization: RemoveProfile could not find", streamKey) + return false, fmt.Errorf("Profile could not be found") + } + + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + err := os.Remove(filepath.Join(profilePath, fileName)) + if err != nil { + return false, err + } + + return true, nil +} + +// Returns the publicly available profile +func GetPublicProfile(bearerToken string) (*PublicProfile, error) { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + assureProfilePath() + + fileName, err := getProfileFileNameByBearerToken(bearerToken) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filepath.Join(profilePath, fileName)) + if err != nil { + return nil, err + } + + var profile Profile + if err := json.Unmarshal(data, &profile); err != nil { + log.Println("Authorization: File", bearerToken, "could not read. File may be corrupt.") + return nil, err + } + profile.FileName = fileName + + return profile.AsPublicProfile(), nil +} + +// Returns the publicly available profile +func GetPersonalProfile(bearerToken string) (*PersonalProfile, error) { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + assureProfilePath() + + fileName, err := getProfileFileNameByBearerToken(bearerToken) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filepath.Join(profilePath, fileName)) + if err != nil { + return nil, err + } + + var profile Profile + if err := json.Unmarshal(data, &profile); err != nil { + log.Println("Authorization: File", bearerToken, "could not read. File may be corrupt.") + return nil, err + } + profile.FileName = fileName + + return profile.AsPersonalProfile(), nil +} + +// Returns a slice of profiles intended for admin endpoints +func GetAdminProfilesAll() (profiles []AdminProfile, err error) { + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + + files, err := os.ReadDir(profilePath) + if err != nil { + log.Println("Authorization: Error reading profile directory", err) + return nil, err + } + + for _, file := range files { + data, err := os.ReadFile(filepath.Join(profilePath, file.Name())) + if err != nil { + profiles = append(profiles, AdminProfile{ + StreamKey: file.Name(), + IsPublic: false, + MOTD: "Error reading profile from file: " + file.Name(), + }) + + continue + } + + var profile Profile + + if err := json.Unmarshal(data, &profile); err != nil { + profiles = append(profiles, AdminProfile{ + StreamKey: file.Name(), + IsPublic: false, + MOTD: "Invalid JSON in file" + file.Name(), + }) + continue + } + + profile.FileName = file.Name() + profiles = append(profiles, *profile.AsAdminProfile()) + } + + return profiles, nil +} + +func IsProfileReserved(streamKey string) bool { + assureProfilePath() + + fileName, _ := getProfileFileNameByStreamKey(streamKey) + return fileName != "" +} + +func ResetProfileToken(streamKey string) error { + fileName, _ := getProfileFileNameByStreamKey(streamKey) + + if fileName == "" { + return fmt.Errorf("authorization: profile could not be found") + } + + profilePath := os.Getenv(environment.STREAM_PROFILE_PATH) + newFileName := streamKey + "_" + generateToken() + currentPath := filepath.Join(profilePath, fileName) + newPath := filepath.Join(profilePath, newFileName) + + if err := os.Rename(currentPath, newPath); err != nil { + return fmt.Errorf("authorization: error updating profile token for %s", streamKey) + } + + return nil +} diff --git a/internal/server/authorization/stream_profile_types.go b/internal/server/authorization/stream_profile_types.go new file mode 100644 index 00000000..b8ff9115 --- /dev/null +++ b/internal/server/authorization/stream_profile_types.go @@ -0,0 +1,72 @@ +package authorization + +import ( + "strings" +) + +// Internal Profile struct, do not use for endpoints +type Profile struct { + FileName string + IsActive bool + IsPublic bool + MOTD string +} + +var separator = "_" + +func (profile *Profile) StreamKey() string { + splitIndex := strings.LastIndex(profile.FileName, separator) + return profile.FileName[:splitIndex+len(separator)-1] +} +func (profile *Profile) StreamToken() string { + splitIndex := strings.LastIndex(profile.FileName, separator) + return profile.FileName[splitIndex+len(separator):] +} +func (profile *Profile) AsPublicProfile() *PublicProfile { + return &PublicProfile{ + StreamKey: profile.StreamKey(), + IsActive: profile.IsActive, + IsPublic: profile.IsPublic, + MOTD: profile.MOTD, + } +} +func (profile *Profile) AsPersonalProfile() *PersonalProfile { + return &PersonalProfile{ + StreamKey: profile.StreamKey(), + IsActive: profile.IsActive, + IsPublic: profile.IsPublic, + MOTD: profile.MOTD, + } +} +func (profile *Profile) AsAdminProfile() *AdminProfile { + return &AdminProfile{ + StreamKey: profile.StreamKey(), + Token: profile.StreamToken(), + IsPublic: profile.IsPublic, + MOTD: profile.MOTD, + } +} + +// Public profile struct for serving to public endpoints +type PublicProfile struct { + StreamKey string `json:"streamKey"` + IsActive bool `json:"isActive"` + IsPublic bool `json:"isPublic"` + MOTD string `json:"motd"` +} + +// Personal profile struct for serving to profile owner endpoints +type PersonalProfile struct { + StreamKey string `json:"streamKey"` + IsActive bool `json:"isActive"` + IsPublic bool `json:"isPublic"` + MOTD string `json:"motd"` +} + +// Admin profile struct for serving to admin specific endpoints +type AdminProfile struct { + StreamKey string `json:"streamKey"` + Token string `json:"token"` + IsPublic bool `json:"isPublic"` + MOTD string `json:"motd"` +} diff --git a/internal/server/handlers/admin/admin_helpers.go b/internal/server/handlers/admin/admin_helpers.go new file mode 100644 index 00000000..8d1b9812 --- /dev/null +++ b/internal/server/handlers/admin/admin_helpers.go @@ -0,0 +1,66 @@ +package admin + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +type SessionResponse struct { + IsValid bool `json:"isValid"` + ErrorMessage string `json:"errorMessage"` +} + +// Verify that a bearer token is provided for an admin session +// A response will be written to the response writter if the session is valid +func verifyAdminSession(request *http.Request) *SessionResponse { + token := helpers.ResolveBearerToken(request.Header.Get("Authorization")) + if token == "" { + log.Println("Authorization was not set") + + return &SessionResponse{ + IsValid: false, + ErrorMessage: "Authorization was invalid", + } + } + + adminApiToken := os.Getenv(environment.FRONTEND_ADMIN_TOKEN) + + if adminApiToken == "" || !strings.EqualFold(adminApiToken, token) { + return &SessionResponse{ + IsValid: false, + ErrorMessage: "Authorization was invalid", + } + } + + return &SessionResponse{ + IsValid: true, + ErrorMessage: "", + } +} + +// Verify the expected method and return true or false if the method is as expected +// This will write a default METHOD NOT ALLOWED response on the responsewriter +func verifyValidMethod(expectedMethod string, responseWriter http.ResponseWriter, request *http.Request) bool { + if !strings.EqualFold(expectedMethod, request.Method) { + helpers.LogHttpError(responseWriter, "Method not allowed", http.StatusMethodNotAllowed) + err := json.NewEncoder(responseWriter).Encode(&SessionResponse{ + IsValid: false, + ErrorMessage: "Method not allowed", + }) + + if err != nil { + log.Println("Admin.Helpers Error:", err) + return false + } + + return false + } + + return true +} diff --git a/internal/server/handlers/admin/admin_logging.go b/internal/server/handlers/admin/admin_logging.go new file mode 100644 index 00000000..cb4aecb4 --- /dev/null +++ b/internal/server/handlers/admin/admin_logging.go @@ -0,0 +1,39 @@ +package admin + +import ( + "io" + "log" + "net/http" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +func AdminLoggingHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("GET", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + file, err := environment.GetLogFileReader() + if err != nil { + log.Println("API.Admin.Logging Error:", err) + } + + responseWriter.Header().Set("Content-Type", "application/json") + + if _, err := io.Copy(responseWriter, file); err != nil { + log.Println("API.Admin.Logging: Error writing file to response", err) + helpers.LogHttpError(responseWriter, "Invalid request", http.StatusBadRequest) + } + + err = file.Close() + if err != nil { + log.Println("API.Admin.Logging Error:", err) + } +} diff --git a/internal/server/handlers/admin/admin_login.go b/internal/server/handlers/admin/admin_login.go new file mode 100644 index 00000000..f3ce7a24 --- /dev/null +++ b/internal/server/handlers/admin/admin_login.go @@ -0,0 +1,29 @@ +package admin + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +func AdminLoginHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("POST", responseWriter, request); !isValidMethod { + return + } + + responseWriter.Header().Set("Content-Type", "application/json") + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + log.Println("Admin login failed") + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + err := json.NewEncoder(responseWriter).Encode(sessionResult) + if err != nil { + log.Println("API.Admin.Login Error", err) + } +} diff --git a/internal/server/handlers/admin/admin_profiles.go b/internal/server/handlers/admin/admin_profiles.go new file mode 100644 index 00000000..b05fe936 --- /dev/null +++ b/internal/server/handlers/admin/admin_profiles.go @@ -0,0 +1,128 @@ +package admin + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +// Retrieve all existing profiles +func AdminProfilesHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("GET", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + profiles, err := authorization.GetAdminProfilesAll() + if err != nil { + return + } + + responseWriter.Header().Set("Content-Type", "application/json") + + err = json.NewEncoder(responseWriter).Encode(profiles) + if err != nil { + log.Println("API.Admin.Profiles Error", err) + } +} + +type adminTokenResetPayload struct { + StreamKey string `json:"streamKey"` +} + +// Reset the token of an existing stream profile +func AdminProfilesResetTokenHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("POST", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + var payload adminTokenResetPayload + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + helpers.LogHttpError(responseWriter, "Error resolving request", http.StatusBadRequest) + return + } + + if err := authorization.ResetProfileToken(payload.StreamKey); err != nil { + log.Println("API.Admin.ProfilesResetTokenHandler", err) + helpers.LogHttpError(responseWriter, "Error updating token", http.StatusBadRequest) + return + } + + responseWriter.WriteHeader(http.StatusOK) +} + +type adminAddStreamPayload struct { + StreamKey string `json:"streamKey"` +} + +// Reset the token of an existing stream profile +func AdminProfileAddHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("POST", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + var payload adminAddStreamPayload + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + helpers.LogHttpError(responseWriter, "Error resolving request", http.StatusBadRequest) + return + } + + if _, err := authorization.CreateProfile(payload.StreamKey); err != nil { + log.Println("API.Admin.CreateProfile", err) + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + responseWriter.WriteHeader(http.StatusOK) +} + +type adminRemoveStreamPayload struct { + StreamKey string `json:"streamKey"` +} + +// Reset the token of an existing stream profile +func AdminProfileRemoveHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("POST", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + var payload adminRemoveStreamPayload + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + helpers.LogHttpError(responseWriter, "Error resolving request", http.StatusBadRequest) + return + } + + if _, err := authorization.RemoveProfile(payload.StreamKey); err != nil { + log.Println("API.Admin.RemoveProfile", err) + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + responseWriter.WriteHeader(http.StatusOK) +} diff --git a/internal/server/handlers/admin/admin_sse.go b/internal/server/handlers/admin/admin_sse.go new file mode 100644 index 00000000..989e20a4 --- /dev/null +++ b/internal/server/handlers/admin/admin_sse.go @@ -0,0 +1,66 @@ +package admin + +import ( +// "fmt" +// "log" +// "net/http" +// "os" +// "strings" +// "github.com/glimesh/broadcast-box/internal/environment" +// "github.com/glimesh/broadcast-box/internal/server/helpers" +// "github.com/glimesh/broadcast-box/internal/webrtc/session" +) + +// func adminSseHandler(responseWriter http.ResponseWriter, request *http.Request) { +// flusher, ok := responseWriter.(http.Flusher) +// if !ok { +// http.Error(responseWriter, "Streaming unsupported", http.StatusInternalServerError) +// return +// } + +// responseWriter.Header().Add("Content-Type", "text/event-stream") +// responseWriter.Header().Add("Cache-Control", "no-cache") +// responseWriter.Header().Add("Connection", "keep-alive") + +// values := strings.Split(request.URL.RequestURI(), "/") +// sessionId := values[len(values)-1] + +// debugSseMessages := strings.EqualFold(os.Getenv(environment.DEBUG_PRINT_SSE_MESSAGES), "true") + +// ctx := request.Context() + +// Setup WHEP/WHIP session for SSE feed +// sseChannel := getWhipSessionChannel(sessionId) +// +// if sseChannel == nil { +// sseChannel = getWhepSessionChannel(sessionId) +// } + +// if sseChannel == nil { +// log.Println("API.Admin.SSE Error: No session could be found") +// helpers.LogHttpError(responseWriter, "Invalid request", http.StatusBadRequest) +// return +// } + +// for { +// select { +// case <-ctx.Done(): +// return +// case msg, ok := <-sseChannel: +// if debugSseMessages { +// log.Println("API.SSE Sending:", msg) +// } +// +// if !ok { +// log.Println("API.SSE: Channel closed") +// return +// } +// +// if _, err := fmt.Fprintf(responseWriter, "%s\n", msg); err != nil { +// log.Println("API.SSE Error:", err) +// } +// +// flusher.Flush() +// } +// } +// } diff --git a/internal/server/handlers/admin/admin_status.go b/internal/server/handlers/admin/admin_status.go new file mode 100644 index 00000000..34eb50b3 --- /dev/null +++ b/internal/server/handlers/admin/admin_status.go @@ -0,0 +1,31 @@ +package admin + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" +) + +func AdminStatusHandler(responseWriter http.ResponseWriter, request *http.Request) { + if isValidMethod := verifyValidMethod("GET", responseWriter, request); !isValidMethod { + return + } + + sessionResult := verifyAdminSession(request) + if !sessionResult.IsValid { + helpers.LogHttpError(responseWriter, sessionResult.ErrorMessage, http.StatusUnauthorized) + return + } + + sessions := manager.SessionsManager.GetSessionStates(true) + + responseWriter.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(responseWriter).Encode(sessions) + if err != nil { + log.Println("API.AdminStatus Error", err) + } +} diff --git a/internal/server/handlers/client_ice.go b/internal/server/handlers/client_ice.go new file mode 100644 index 00000000..758f4a5c --- /dev/null +++ b/internal/server/handlers/client_ice.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +type ICEComponentServer struct { + Urls string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` +} + +func clientICEHandler(responseWriter http.ResponseWriter, request *http.Request) { + turnServers := os.Getenv(environment.TURN_SERVERS) + turnAuthKey := os.Getenv(environment.TURN_SERVER_AUTH_SECRET) + stunServers := os.Getenv(environment.STUN_SERVERS) + var servers []ICEComponentServer + + if turnServers == "" && stunServers == "" { + _, err := responseWriter.Write([]byte("[]")) + if err != nil { + log.Println("error writing empty TURN/STUN response", err) + } + + return + } + + if turnServers != "" { + turnServerNames := strings.Split(turnServers, "|") + for server := range turnServerNames { + log.Println("Adding TURN server", server) + + if turnAuthKey != "" { + username, credentials := authorization.GetTURNCredentials() + + servers = append(servers, ICEComponentServer{ + Urls: "turn:" + turnServerNames[server], + Username: username, + Credential: credentials, + }) + } else { + servers = append(servers, ICEComponentServer{ + Urls: "turn:" + turnServerNames[server], + }) + } + } + + } + + if stunServers != "" { + stunServerNames := strings.Split(stunServers, "|") + for server := range stunServerNames { + servers = append(servers, ICEComponentServer{ + Urls: "stun:" + stunServerNames[server], + }) + } + } + + if len(servers) == 0 { + _, err := responseWriter.Write([]byte("[]")) + if err != nil { + log.Println("error writing empty TURN/STUN response", err) + } + + return + } + + if err := json.NewEncoder(responseWriter).Encode(servers); err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + log.Println(err.Error()) + } + + responseWriter.Header().Add("Content-Type", "application/json") +} diff --git a/internal/server/handlers/handlers.go b/internal/server/handlers/handlers.go new file mode 100644 index 00000000..2b43263f --- /dev/null +++ b/internal/server/handlers/handlers.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "errors" + "log" + "net/http" + "os" + "path" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + adminHandlers "github.com/glimesh/broadcast-box/internal/server/handlers/admin" + whipHandlers "github.com/glimesh/broadcast-box/internal/server/handlers/whip" +) + +func GetServeMuxHandler() http.HandlerFunc { + serverMux := http.NewServeMux() + + if os.Getenv(environment.FRONTEND_DISABLED) == "" { + serverMux.HandleFunc("/", frontendHandler) + } + + // Whip/Whep shared endpoints + serverMux.HandleFunc("/api/whep", corsHandler(WhepHandler)) + serverMux.HandleFunc("/api/whep/", corsHandler(WhepHandler)) + serverMux.HandleFunc("/api/sse/", corsHandler(sseHandler)) + serverMux.HandleFunc("/api/ice-servers", corsHandler(clientICEHandler)) + + // Whip session endpoints + serverMux.HandleFunc("/api/whip", corsHandler(whipHandlers.WhipHandler)) + serverMux.HandleFunc("/api/whip/", corsHandler(whipHandlers.WhipHandler)) + serverMux.HandleFunc("/api/whip/profile", corsHandler(whipHandlers.ProfileHandler)) + + // Whep session endpoints + serverMux.HandleFunc("/api/layer/", corsHandler(layerChangeHandler)) + + // Logging and status endpoints + serverMux.HandleFunc("/api/log", corsHandler(logHandler)) + serverMux.HandleFunc("/api/status", corsHandler(statusHandler)) + + // Admin endpoints + // serverMux.HandleFunc("/api/admin/sse/", corsHandler(adminSseHandler)) + serverMux.HandleFunc("/api/admin/login", corsHandler(adminHandlers.AdminLoginHandler)) + serverMux.HandleFunc("/api/admin/status", corsHandler(adminHandlers.AdminStatusHandler)) + serverMux.HandleFunc("/api/admin/logging", corsHandler(adminHandlers.AdminLoggingHandler)) + serverMux.HandleFunc("/api/admin/profiles", corsHandler(adminHandlers.AdminProfilesHandler)) + serverMux.HandleFunc("/api/admin/profiles/reset-token", corsHandler(adminHandlers.AdminProfilesResetTokenHandler)) + serverMux.HandleFunc("/api/admin/profiles/add-profile", corsHandler(adminHandlers.AdminProfileAddHandler)) + serverMux.HandleFunc("/api/admin/profiles/remove-profile", corsHandler(adminHandlers.AdminProfileRemoveHandler)) + + // Path middleware + debugOutputWebRequests := os.Getenv(environment.DEBUG_INCOMING_API_REQUEST) + handler := http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + if strings.EqualFold(debugOutputWebRequests, "TRUE") { + log.Println("Calling path", request.URL.Path) + _, pattern := serverMux.Handler(request) + + if pattern == "" { + log.Println("Unmatched path:", request.URL.Path) + } else { + log.Println("Found pattern", pattern) + } + } + + serverMux.ServeHTTP(responseWriter, request) + }) + + return handler +} + +func RedirectToHttpsHandler(httpWriter http.ResponseWriter, request *http.Request) { + http.Redirect(httpWriter, request, "https://"+request.Host+request.URL.String(), http.StatusMovedPermanently) +} + +func corsHandler(next func(responseWriter http.ResponseWriter, request *http.Request)) http.HandlerFunc { + return func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Access-Control-Allow-Origin", "*") + response.Header().Set("Access-Control-Allow-Methods", "*") + response.Header().Set("Access-Control-Allow-Headers", "*") + response.Header().Set("Access-Control-Expose-Headers", "*") + + if request.Method != http.MethodOptions { + next(response, request) + } + } +} + +func frontendHandler(response http.ResponseWriter, request *http.Request) { + + defaultFrontendPath := "./web/build" + + frontendFilePath := os.Getenv(environment.FRONTEND_PATH) + + if frontendFilePath == "" { + frontendFilePath = defaultFrontendPath + } + + fileSystem := http.Dir(frontendFilePath) + fileServer := http.FileServer(fileSystem) + _, err := fileSystem.Open(path.Clean(request.URL.Path)) + + if errors.Is(err, os.ErrNotExist) { + http.ServeFile(response, request, frontendFilePath+"/index.html") + } else { + fileServer.ServeHTTP(response, request) + } +} diff --git a/internal/server/handlers/layerchange.go b/internal/server/handlers/layerchange.go new file mode 100644 index 00000000..9838b755 --- /dev/null +++ b/internal/server/handlers/layerchange.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" +) + +type ( + whepLayerRequestJson struct { + MediaId string `json:"mediaId"` + EncodingId string `json:"encodingId"` + } +) + +func layerChangeHandler(responseWriter http.ResponseWriter, request *http.Request) { + var requestContent whepLayerRequestJson + + if err := json.NewDecoder(request.Body).Decode(&requestContent); err != nil { + helpers.LogHttpError(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + values := strings.Split(request.URL.RequestURI(), "/") + whepSessionId := values[len(values)-1] + whepSession, ok := manager.SessionsManager.GetWhepSessionById(whepSessionId) + + log.Println("Found WHEP session", whepSession.SessionId) + + if !ok { + helpers.LogHttpError(responseWriter, "Could not find WHEP session", http.StatusBadRequest) + return + } + + if requestContent.MediaId == "1" { + log.Println("Setting Video Layer", requestContent.EncodingId) + whepSession.SetVideoLayer(requestContent.EncodingId) + return + } + + if requestContent.MediaId == "2" { + log.Println("Setting Audio Layer", requestContent.EncodingId) + whepSession.SetAudioLayer(requestContent.EncodingId) + return + } + + helpers.LogHttpError(responseWriter, "Unknown media type", http.StatusBadRequest) +} diff --git a/internal/server/handlers/log.go b/internal/server/handlers/log.go new file mode 100644 index 00000000..953b1c1b --- /dev/null +++ b/internal/server/handlers/log.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/helpers" +) + +func logHandler(responseWriter http.ResponseWriter, request *http.Request) { + if !strings.EqualFold(os.Getenv(environment.LOGGING_API_ENABLED), "true") { + return + } + + if apiKey := os.Getenv(environment.LOGGING_API_KEY); apiKey != "" { + authHeader := request.Header.Get("Authorization") + token := helpers.ResolveBearerToken(authHeader) + + if token == "" { + helpers.LogHttpError(responseWriter, "Authorization was invalid", http.StatusUnauthorized) + + return + } else if token != apiKey { + helpers.LogHttpError(responseWriter, "Authorization was invalid", http.StatusUnauthorized) + + return + } + } + + file, err := environment.GetLogFileReader() + if err != nil { + log.Println("API.Log Error:", err) + } + + responseWriter.Header().Set("Content-Type", "text/plain") + + if _, err := io.Copy(responseWriter, file); err != nil { + log.Println("API.Log: Error writing file to response", err) + helpers.LogHttpError(responseWriter, "Invalid request", http.StatusBadRequest) + } + + err = file.Close() + if err != nil { + log.Println("API.Log Error:", err) + } +} diff --git a/internal/server/handlers/sse.go b/internal/server/handlers/sse.go new file mode 100644 index 00000000..576e555e --- /dev/null +++ b/internal/server/handlers/sse.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" +) + +func sseHandler(responseWriter http.ResponseWriter, request *http.Request) { + flusher, ok := responseWriter.(http.Flusher) + if !ok { + http.Error(responseWriter, "Streaming unsupported", http.StatusInternalServerError) + return + } + + responseWriter.Header().Add("Content-Type", "text/event-stream") + responseWriter.Header().Add("Cache-Control", "no-cache") + responseWriter.Header().Add("Connection", "keep-alive") + + values := strings.Split(request.URL.RequestURI(), "/") + sessionId := values[len(values)-1] + + debugSseMessages := strings.EqualFold(os.Getenv(environment.DEBUG_PRINT_SSE_MESSAGES), "true") + + ctx := request.Context() + + // Setup WHEP/WHIP session for SSE feed + sseChannel := getWhipSessionChannel(sessionId) + + if sseChannel == nil { + sseChannel = getWhepSessionChannel(sessionId) + } + + if sseChannel == nil { + helpers.LogHttpError(responseWriter, "Invalid request", http.StatusBadRequest) + return + } + + for { + select { + case <-ctx.Done(): + log.Println("API.SSE: Client disconnected") + return + + case msg, ok := <-sseChannel: + if debugSseMessages { + log.Println("API.SSE Sending:", msg) + } + + if !ok || msg == "close" { + log.Println("API.SSE: Channel closed") + return + } + + // Write with timeout + writeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + done := make(chan error, 1) + + go func() { + _, err := fmt.Fprintf(responseWriter, "%s\n", msg) + if err == nil { + flusher.Flush() + } + done <- err + }() + + select { + case err := <-done: + cancel() + if err != nil { + log.Println("API.SSE Write error:", err) + return + } + case <-writeCtx.Done(): + cancel() + log.Println("API.SSE Write timeout") + return + } + } + } +} + +func getWhipSessionChannel(sessionId string) chan any { + var channel chan any + whipSession, ok := manager.SessionsManager.GetHostSessionById(sessionId) + + if ok { + channel = whipSession.EventsChannel + } + + return channel +} + +func getWhepSessionChannel(sessionId string) chan any { + var channel chan any + whepSession, ok := manager.SessionsManager.GetWhepSessionById(sessionId) + + if ok { + channel = whepSession.SseEventsChannel + } + + return channel +} diff --git a/internal/server/handlers/status.go b/internal/server/handlers/status.go new file mode 100644 index 00000000..1bcb4b3b --- /dev/null +++ b/internal/server/handlers/status.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "os" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" +) + +func statusHandler(responseWriter http.ResponseWriter, request *http.Request) { + streamKey := helpers.GetStreamKey(request) + + if streamKey == "" { + sessionStatusesHandler(responseWriter, request) + } else { + streamStatusHandler(responseWriter, request) + } + + responseWriter.Header().Add("Content-Type", "application/json") +} + +func streamStatusHandler(responseWriter http.ResponseWriter, request *http.Request) { + streamKey := helpers.GetStreamKey(request) + + session, ok := manager.SessionsManager.GetSessionById(streamKey) + + if !ok { + log.Println("Could not find active stream", streamKey) + helpers.LogHttpError( + responseWriter, + "No active stream found", + http.StatusNotFound) + + return + } + + statusResult := session.GetStreamStatus() + + if err := json.NewEncoder(responseWriter).Encode(statusResult); err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + log.Println(err.Error()) + } + + responseWriter.Header().Add("Content-Type", "application/json") +} + +func sessionStatusesHandler(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method == "DELETE" { + return + } + + if isDisabled := os.Getenv(environment.DISABLE_STATUS); isDisabled != "" { + helpers.LogHttpError( + responseWriter, + "Status Service Unavailable", + http.StatusServiceUnavailable) + + return + } + + if err := json.NewEncoder(responseWriter).Encode(manager.SessionsManager.GetSessionStates(false)); err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + + log.Println(err.Error()) + } + + responseWriter.Header().Add("Content-Type", "application/json") +} diff --git a/internal/server/handlers/whep.go b/internal/server/handlers/whep.go new file mode 100644 index 00000000..7eebd856 --- /dev/null +++ b/internal/server/handlers/whep.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "mime" + "net/http" + "strings" + + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc" + "github.com/glimesh/broadcast-box/internal/webrtc/utils" +) + +type WhepRequest struct { + Offer string + StreamKey string +} + +func WhepHandler(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method != "POST" && request.Method != "PATCH" { + return + } + + requestBodyB64, err := io.ReadAll(request.Body) + if err != nil { + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + // TODO: Is decodedB64 neccesarry? + decodedB64, err := base64.StdEncoding.DecodeString(string(requestBodyB64)) + if err != nil { + log.Println("API.WHEP: Invalid B64 encoding for request") + return + } + + if request.Method == "PATCH" { + if err := utils.ValidateOffer(string(decodedB64)); err != nil { + helpers.LogHttpError(responseWriter, "invalid offer: "+err.Error(), http.StatusBadRequest) + return + } + + path := strings.Replace(request.URL.Path, "/api/whep", "", 1) + segments := strings.Split(path, "/") + sessionId := strings.TrimSpace(segments[len(segments)-1]) + + if sessionId == "" { + log.Println("API.WHEP.Patch Error: Missing session id") + helpers.LogHttpError(responseWriter, "Missing session id", http.StatusBadRequest) + return + } + + log.Println("API.WHEP.Patch: Patching session", sessionId) + if err := patchHandler(responseWriter, request, sessionId, string(decodedB64)); err != nil { + log.Println("API.WHEP.Patch Error:", err) + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + } + + return + } + + var whepRequest WhepRequest + if err := json.Unmarshal(decodedB64, &whepRequest); err != nil { + log.Println("API.WHEP: Could not read WHEP request") + return + } + + whipAnswer, sessionId, err := webrtc.WHEP(string(whepRequest.Offer), whepRequest.StreamKey) + if err != nil { + log.Println("API.WHEP: Setup Error", err.Error()) + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + responseWriter.Header().Add("Link", `<`+"/api/sse/"+sessionId+`>; rel="urn:ietf:params:whep:ext:core:server-sent-events"; events="layers"`) + responseWriter.Header().Add("Link", `<`+"/api/layer/"+sessionId+`>; rel="urn:ietf:params:whep:ext:core:layer"`) + + responseWriter.Header().Add("Location", "/api/whep/"+sessionId) + responseWriter.Header().Add("Content-Type", "application/sdp") + responseWriter.WriteHeader(http.StatusCreated) + + if _, err = fmt.Fprint(responseWriter, whipAnswer); err != nil { + log.Println("API.WHEP:", err) + } else { + log.Println("API.WHEP: Completed") + } +} + +func patchHandler(res http.ResponseWriter, r *http.Request, sessionId, body string) error { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/trickle-ice-sdpfrag" { + helpers.LogHttpError(res, "invalid content type", http.StatusUnsupportedMediaType) + return err + } + + if err = webrtc.HandleWhepPatch(sessionId, body); err != nil { + return err + } + + res.WriteHeader(http.StatusNoContent) + + return nil +} diff --git a/internal/server/handlers/whip/profile.go b/internal/server/handlers/whip/profile.go new file mode 100644 index 00000000..24016eac --- /dev/null +++ b/internal/server/handlers/whip/profile.go @@ -0,0 +1,79 @@ +package whip + +import ( + "encoding/json" + "io" + "log" + "net/http" + + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" +) + +type updateProfilePayload struct { + Motd string `json:"motd"` + IsPublic bool `json:"isPublic"` +} + +func ProfileHandler(responseWriter http.ResponseWriter, request *http.Request) { + token := helpers.ResolveBearerToken(request.Header.Get("Authorization")) + + // Get Profile + if request.Method == "GET" { + profile, err := authorization.GetPersonalProfile(token) + + if err != nil { + helpers.LogHttpError( + responseWriter, + "Profile not found", + http.StatusNoContent) + + return + } + + if err := json.NewEncoder(responseWriter).Encode(profile); err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + log.Println(err.Error()) + } + + responseWriter.Header().Add("Content-Type", "application/json") + } + + // Update Profile + if request.Method == "POST" { + log.Println("Updating Profile") + + body, _ := io.ReadAll(request.Body) + var payload updateProfilePayload + if err := json.Unmarshal(body, &payload); err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + log.Println("Profile Update Error:", err) + return + } + + // Update stored profile + err := authorization.UpdateProfile(token, payload.Motd, payload.IsPublic) + if err != nil { + helpers.LogHttpError( + responseWriter, + "Internal Server Error", + http.StatusInternalServerError) + log.Println(err.Error()) + return + } + + profile, _ := authorization.GetPersonalProfile(token) + + // Update current session + manager.SessionsManager.UpdateProfile(profile) + } + + responseWriter.Header().Add("Content-Type", "application/json") +} diff --git a/internal/server/handlers/whip/whip.go b/internal/server/handlers/whip/whip.go new file mode 100644 index 00000000..5afc1f2c --- /dev/null +++ b/internal/server/handlers/whip/whip.go @@ -0,0 +1,161 @@ +package whip + +import ( + "fmt" + "io" + "log" + "mime" + "net/http" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/server/helpers" + "github.com/glimesh/broadcast-box/internal/server/webhook" + "github.com/glimesh/broadcast-box/internal/webrtc" +) + +func WhipHandler(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method != "POST" && request.Method != "PATCH" { + return + } + + authHeader := request.Header.Get("Authorization") + + if authHeader == "" { + log.Println("Authorization was not set") + helpers.LogHttpError(responseWriter, "Authorization was not set", http.StatusBadRequest) + } + + token := helpers.ResolveBearerToken(authHeader) + if token == "" { + log.Println("Authorization was invalid") + helpers.LogHttpError(responseWriter, "Authorization was invalid", http.StatusUnauthorized) + return + } + + offer, err := io.ReadAll(request.Body) + if err != nil || string(offer) == "" { + log.Println("Error reading offer") + helpers.LogHttpError(responseWriter, "error reading offer", http.StatusBadRequest) + return + } + + var userProfile authorization.PublicProfile + + // Stream requires webhook validation + if webhookUrl := os.Getenv(environment.WEBHOOK_URL); webhookUrl != "" { + streamKey, err := webhook.CallWebhook(webhookUrl, webhook.WhipConnect, token, request) + if err != nil { + responseWriter.WriteHeader(http.StatusUnauthorized) + return + } + + userProfile = authorization.PublicProfile{ + StreamKey: streamKey, + IsPublic: true, + MOTD: "Welcome to " + streamKey + "'s stream!", + } + } + + // Stream profile policy + switch os.Getenv(environment.STREAM_PROFILE_POLICY) { + // Only approved profiles are allowed to stream + case authorization.STREAM_POLICY_RESERVED_ONLY: + log.Println("Policy:", authorization.STREAM_POLICY_RESERVED_ONLY) + profile, err := authorization.GetPublicProfile(token) + if err != nil { + log.Println("Unauthorized login attempt with bearer", token) + responseWriter.WriteHeader(http.StatusUnauthorized) + return + } + userProfile = *profile + + default: + log.Println("Policy:", authorization.STREAM_POLICY_WITH_RESERVED) + + // If using a streamKey check if it has been reserved + if authorization.IsProfileReserved(token) { + log.Println("Unauthorized login attempt with bearer", token, " - Streamkey has been reserved") + responseWriter.WriteHeader(http.StatusUnauthorized) + return + } + + // If its a bearer token, validate and use the profile + profile, _ := authorization.GetPublicProfile(token) + if profile != nil { + userProfile = *profile + } + } + + // Set default profile in case none is set + if userProfile == (authorization.PublicProfile{}) { + userProfile = authorization.PublicProfile{ + StreamKey: token, + IsPublic: true, + MOTD: "Welcome to " + token + "'s stream!", + } + } + + if request.Method == "PATCH" { + + if contentType := request.Header.Get("Content-Type"); contentType != "application/trickle-ice-sdpfrag" { + log.Println("API.WHIP.Patch Error: Invalid patch request") + helpers.LogHttpError(responseWriter, "Invalid patch request", http.StatusBadRequest) + return + } + + path := strings.Replace(request.URL.Path, "/api/whip", "", 1) + segments := strings.Split(path, "/") + sessionId := strings.TrimSpace(segments[len(segments)-1]) + + if sessionId == "" { + log.Println("API.WHIP.Patch Error: Missing session id") + helpers.LogHttpError(responseWriter, "Missing session id", http.StatusBadRequest) + return + } + + log.Println("API.WHIP.Patch: Patching session", sessionId) + if err := patchHandler(responseWriter, request, sessionId, string(offer)); err != nil { + log.Println("API.WHIP.Patch Error:", err) + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + } + + return + } + + whipAnswer, sessionId, err := webrtc.WHIP(string(offer), userProfile) + if err != nil { + helpers.LogHttpError(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + responseWriter.Header().Add("Link", `<`+"/api/sse/"+sessionId+`>; rel="urn:ietf:params:whep:ext:core:server-sent-events"; events="status"`) + responseWriter.Header().Add("Location", "/api/whip/"+sessionId) + responseWriter.Header().Add("Content-Type", "application/sdp") + responseWriter.WriteHeader(http.StatusCreated) + + if _, err = fmt.Fprint(responseWriter, whipAnswer); err != nil { + log.Println("API.WHIP Error", err) + } else { + log.Println("API.WHIP Completed") + } + +} + +func patchHandler(res http.ResponseWriter, r *http.Request, sessionId, body string) error { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/trickle-ice-sdpfrag" { + helpers.LogHttpError(res, "invalid content type", http.StatusUnsupportedMediaType) + return err + } + + if err = webrtc.HandleWhipPatch(sessionId, body); err != nil { + return err + } + + res.WriteHeader(http.StatusNoContent) + + return nil +} diff --git a/internal/server/helpers/log.go b/internal/server/helpers/log.go new file mode 100644 index 00000000..46533e25 --- /dev/null +++ b/internal/server/helpers/log.go @@ -0,0 +1,11 @@ +package helpers + +import ( + "log" + "net/http" +) + +func LogHttpError(responseWriter http.ResponseWriter, error string, code int) { + log.Println("LogHttpError", error) + http.Error(responseWriter, error, code) +} diff --git a/internal/server/helpers/requestHelpers.go b/internal/server/helpers/requestHelpers.go new file mode 100644 index 00000000..15d47834 --- /dev/null +++ b/internal/server/helpers/requestHelpers.go @@ -0,0 +1,10 @@ +package helpers + +import "net/http" + +func GetStreamKey(request *http.Request) (streamKey string) { + queries := request.URL.Query() + streamKey = queries.Get("key") + + return streamKey +} diff --git a/internal/server/helpers/token.go b/internal/server/helpers/token.go new file mode 100644 index 00000000..c17ea8d1 --- /dev/null +++ b/internal/server/helpers/token.go @@ -0,0 +1,56 @@ +package helpers + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// Resolve Bearer token. +// This supports both a B64 token as well as a regular ASCII token to allow for +// using special characters for stream keys that are not tokens +func ResolveBearerToken(authHeader string) (result string) { + const bearerPrefix = "Bearer " + + // Cut the prefix + if auth, ok := strings.CutPrefix(authHeader, bearerPrefix); ok { + + // Check for valid b64 + if base64String, err := getBase64String(auth); err == nil { + return strings.ReplaceAll(base64String, " ", "_") + + // Invalid, handle as unicode + } else { + re := regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + + auth = strings.TrimSpace(auth) + auth = strings.ReplaceAll(auth, " ", "_") + if re.MatchString(auth) { + return strings.ReplaceAll(auth, " ", "_") + } + } + } + + return "" +} + +// In case the bearer token is encoded in Base64, it can be resolved before return. This +// allows for UTF-8 character support in headers with bearer tokens +func getBase64String(token string) (result string, err error) { + if !regexp.MustCompile(`^[A-Za-z0-9+/]+={0,2}$`).MatchString(token) { + return "", fmt.Errorf("string is not valid base64") + } + + output, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return "", err + } + + if !utf8.Valid(output) { + return token, nil + } + + return string(output), nil +} diff --git a/internal/server/helpers/token_test.go b/internal/server/helpers/token_test.go new file mode 100644 index 00000000..f2fa958c --- /dev/null +++ b/internal/server/helpers/token_test.go @@ -0,0 +1,119 @@ +package helpers + +import "testing" + +func Test_getBase64String(t *testing.T) { + tests := []struct { + name string + token string + want string + wantErr bool + }{ + { + name: "Valid Base64 'Hello'", + token: "aGVsbG8=", + want: "hello", + wantErr: false, + }, + { + name: "Valid Base64 UTF8 - 'æøå\n'", + token: "w6bDuMOlCg==", + want: "æøå\n", + wantErr: false, + }, + { + name: "Valid Base64 / Invalid UTF8", + token: "////", + wantErr: false, + want: "////", + }, + { + name: "Invalid Base64 characters", + token: "abc$123", + wantErr: true, + }, + { + name: "Invalid padding", + token: "aGVsbG8===", + wantErr: true, + }, + { + name: "Invalid Empty string", + token: "", + wantErr: true, + }, + { + name: "Invalid Base64 with whitespace", + token: "aG Vs bG8=", + wantErr: true, + }, + { + name: "Invalid Base64 corrupted padding", + token: "YW55IGNhcm5hbCBwbGVhcw", + wantErr: true, + }, + { + name: "Invalid Base64 corrupted padding", + token: "YW55IGNhcm5hbCBwbGVhcw", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotErr := getBase64String(tt.token) + + if gotErr != nil { + if !tt.wantErr { + t.Errorf("getBase64String() failed: %v", gotErr) + } + return + } + + if tt.wantErr { + t.Fatalf("getBase64String() succeeded unexpectedly = %q", got) + } + + if got != tt.want { + t.Errorf("getBase64String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveBearerToken(t *testing.T) { + tests := []struct { + name string + authHeader string + want string + }{ + { + name: "Invalid: Non-B64 AuthHeader Unicode", + authHeader: "Møl", + want: "", + }, + { + name: "Valid: B64 AuthHeader Unicode", + authHeader: "TcO4bA==", + want: "Møl", + }, + { + name: "Valid: Non-B64 AuthHeader ASCII", + authHeader: "Formula1", + want: "Formula1", + }, + { + name: "Valid: Non-B64 AuthHeader ASCII with whitespace", + authHeader: "Formula 1", + want: "Formula_1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveBearerToken("Bearer " + tt.authHeader) + if got != tt.want { + t.Errorf("ResolveBearerToken(): (%s) should be (%v)", got, tt.want) + } + }) + } +} diff --git a/internal/server/http.go b/internal/server/http.go new file mode 100644 index 00000000..ae3a2c64 --- /dev/null +++ b/internal/server/http.go @@ -0,0 +1,59 @@ +package server + +import ( + "log" + "net/http" + "os" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/handlers" +) + +var ( + defaultHttpAddress string = ":80" + defaultHttpRedirectAddress string = ":80" +) + +func startHttpServer(serverMux http.HandlerFunc) { + server := &http.Server{ + Handler: serverMux, + Addr: getHttpAddress(), + } + + log.Println("Starting HTTP server at", getHttpAddress()) + log.Fatal(server.ListenAndServe()) +} + +func getHttpAddress() string { + if httpAddress := os.Getenv(environment.HTTP_ADDRESS); httpAddress != "" { + return httpAddress + } + + return defaultHttpAddress +} + +func setupHttpRedirect() { + if shouldRedirectToHttps := os.Getenv(environment.HTTP_ENABLE_REDIRECT); shouldRedirectToHttps != "" { + httpRedirectPort := defaultHttpRedirectAddress + + if httpRedirectPortEnvVar := os.Getenv(environment.HTTPS_REDIRECT_PORT); httpRedirectPortEnvVar != "" { + httpRedirectPort = httpRedirectPortEnvVar + } + + go func() { + log.Println("Setting up HTTP Redirecting") + + redirectServer := &http.Server{ + Addr: httpRedirectPort, + Handler: http.HandlerFunc(handlers.RedirectToHttpsHandler), + } + + log.Println("Forwarding requests from", redirectServer.Addr, "to HTTPS server") + err := redirectServer.ListenAndServe() + + if err != nil { + log.Fatal(err) + } + }() + } +} diff --git a/internal/server/https.go b/internal/server/https.go new file mode 100644 index 00000000..6c98adeb --- /dev/null +++ b/internal/server/https.go @@ -0,0 +1,53 @@ +package server + +import ( + "crypto/tls" + "log" + "net/http" + "os" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +var ( + defaultHttpsAddress string = ":443" +) + +func startHttpsServer(serverMux http.HandlerFunc) { + sslKey := os.Getenv(environment.SSL_KEY) + sslCert := os.Getenv(environment.SSL_CERT) + + if sslKey == "" { + log.Fatal("Missing SSL Key") + } + if sslCert == "" { + log.Fatal("Missing SSL Certificate") + } + + server := &http.Server{ + Handler: serverMux, + Addr: getHttpsAddress(), + } + + cert, err := tls.LoadX509KeyPair(sslCert, sslKey) + if err != nil { + log.Fatal(err) + } + + server.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + } + + log.Println("Serving HTTPS server at", getHttpsAddress()) + log.Fatal(server.ListenAndServeTLS("", "")) +} + +func getHttpsAddress() string { + + if httpsAddress := os.Getenv(environment.HTTP_ADDRESS); httpsAddress != "" { + return httpsAddress + } + + return defaultHttpsAddress +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 00000000..ffdc3fe0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,21 @@ +package server + +import ( + "os" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/handlers" +) + +// HTTP Setup +func StartWebServer() { + setupHttpRedirect() + + serverMux := handlers.GetServeMuxHandler() + + if os.Getenv(environment.SSL_KEY) != "" && os.Getenv(environment.SSL_CERT) != "" { + startHttpsServer(serverMux) + } else { + startHttpServer(serverMux) + } +} diff --git a/internal/webhook/webhook.go b/internal/server/webhook/webhook.go similarity index 68% rename from internal/webhook/webhook.go rename to internal/server/webhook/webhook.go index 6d17a283..f14536df 100644 --- a/internal/webhook/webhook.go +++ b/internal/server/webhook/webhook.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "log" "net/http" "time" ) @@ -11,7 +12,7 @@ import ( const defaultTimeout = time.Second * 5 type webhookPayload struct { - Action string `json:"action"` + Action Action `json:"action"` IP string `json:"ip"` BearerToken string `json:"bearerToken"` QueryParams map[string]string `json:"queryParams"` @@ -22,11 +23,18 @@ type webhookResponse struct { StreamKey string `json:"streamKey"` } -func CallWebhook(url, action, bearerToken string, r *http.Request) (string, error) { +type Action string + +const ( + WhipConnect Action = "whip-connect" + WhepConnect Action = "whep-connect" +) + +func CallWebhook(url string, action Action, bearerToken string, request *http.Request) (string, error) { start := time.Now() queryParams := make(map[string]string) - for k, v := range r.URL.Query() { + for k, v := range request.URL.Query() { if len(v) > 0 { queryParams[k] = v[0] } @@ -34,28 +42,37 @@ func CallWebhook(url, action, bearerToken string, r *http.Request) (string, erro jsonPayload, err := json.Marshal(webhookPayload{ Action: action, - IP: getIPAddress(r), + IP: getIPAddress(request), BearerToken: bearerToken, QueryParams: queryParams, - UserAgent: r.UserAgent(), + UserAgent: request.UserAgent(), }) + if err != nil { return "", fmt.Errorf("failed to marshal payload: %w", err) } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + webhookRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Content-Type", "application/json") + + webhookRequest.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{ Timeout: defaultTimeout, - }).Do(req) + }).Do(webhookRequest) + if err != nil { return "", fmt.Errorf("webhook request failed after %v: %w", time.Since(start), err) } - defer resp.Body.Close() //nolint + + defer func() { + err := resp.Body.Close() + if err != nil { + log.Println("webhook request failed closing response body") + } + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("webhook returned non-200 Status: %v", resp.StatusCode) diff --git a/internal/webhook/webhook_test.go b/internal/server/webhook/webhook_test.go similarity index 100% rename from internal/webhook/webhook_test.go rename to internal/server/webhook/webhook_test.go diff --git a/internal/networktest/networktest.go b/internal/test/networktest.go similarity index 69% rename from internal/networktest/networktest.go rename to internal/test/networktest.go index eb76fe0e..894e9293 100644 --- a/internal/networktest/networktest.go +++ b/internal/test/networktest.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io" + "log" "net" "net/http" "net/http/httptest" + "os" "strings" "time" @@ -15,15 +17,35 @@ import ( "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" - internalwebrtc "github.com/glimesh/broadcast-box/internal/webrtc" + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/handlers" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" ) -func Run(whepHandler func(res http.ResponseWriter, req *http.Request)) error { - m := &webrtc.MediaEngine{} - if err := internalwebrtc.PopulateMediaEngine(m); err != nil { - return err +const ( + networkTestIntroMessage = "\033[0;33mNETWORK_TEST_ON_START is enabled. If the test fails Broadcast Box will exit.\nSee the README for how to debug or disable NETWORK_TEST_ON_START\033[0m" + networkTestSuccessMessage = "\033[0;32mNetwork Test passed.\nHave fun using Broadcast Box.\033[0m" + networkTestFailedMessage = "\033[0;31mNetwork Test failed.\n%s\nPlease see the README and join Discord for help\033[0m" +) + +func RunNetworkTest() { + + fmt.Println(networkTestIntroMessage) + + err := run(handlers.WhepHandler) + if err != nil { + fmt.Printf(networkTestFailedMessage, err) + os.Exit(1) } + fmt.Println(networkTestSuccessMessage) +} + +func run(whepHandler func(res http.ResponseWriter, req *http.Request)) error { + m := &webrtc.MediaEngine{} + + codecs.RegisterCodecs(m) + s := webrtc.SettingEngine{} s.SetNetworkTypes([]webrtc.NetworkType{ webrtc.NetworkTypeUDP4, @@ -88,6 +110,8 @@ func Run(whepHandler func(res http.ResponseWriter, req *http.Request)) error { return err } + httpAddress := os.Getenv(environment.HTTP_ADDRESS) + firstMediaSection := answerParsed.MediaDescriptions[0] filteredAttributes := []sdp.Attribute{} for i := range firstMediaSection.Attributes { @@ -104,14 +128,32 @@ func Run(whepHandler func(res http.ResponseWriter, req *http.Request)) error { return fmt.Errorf("candidate with invalid IP %s", c.Address()) } + if httpAddress != "" && httpAddress == ip.String() { + log.Println("Found match for HTTP_ADDRESS", ip) + filteredAttributes = append(filteredAttributes, a) + break + } + if !ip.IsPrivate() { filteredAttributes = append(filteredAttributes, a) } + } else { filteredAttributes = append(filteredAttributes, a) } + } + firstMediaSection.Attributes = filteredAttributes + candidateString, candidateExists := firstMediaSection.Attribute("candidate") + if candidateExists { + candidate, err := ice.UnmarshalCandidate(candidateString) + if err != nil { + log.Println("Error unmarshalling candidate") + } + + log.Println("Using test address:", candidate.Address()) + } answer, err := answerParsed.Marshal() if err != nil { diff --git a/internal/webrtc/codecs/codecs.go b/internal/webrtc/codecs/codecs.go new file mode 100644 index 00000000..cdf84311 --- /dev/null +++ b/internal/webrtc/codecs/codecs.go @@ -0,0 +1,197 @@ +package codecs + +import ( + "strings" + + "github.com/pion/webrtc/v4" +) + +type TrackCodeType uint + +const ( + AudioTrackLabelDefault = "Audio" + VideoTrackLabelDefault = "Video" +) +const ( + VideoTrackCodecH264 TrackCodeType = iota + 1 + VideoTrackCodecH265 + VideoTrackCodecVP8 + VideoTrackCodecVP9 + VideoTrackCodecAV1 + + AudioTrackCodecOpus +) + +var VideoRTCPFeedback = []webrtc.RTCPFeedback{ + {Type: "goog-remb", Parameter: ""}, + {Type: "ccm", Parameter: "fir"}, + {Type: "nack", Parameter: ""}, + {Type: "nack", Parameter: "pli"}, +} + +var VideoCodecs = []webrtc.RTPCodecParameters{ + { + PayloadType: 96, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 102, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 103, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 104, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 106, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 108, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 39, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 45, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeAV1, + ClockRate: 90000, + SDPFmtpLine: "", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 98, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeVP9, + ClockRate: 90000, + SDPFmtpLine: "profile-id=0", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 100, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeVP9, + ClockRate: 90000, + SDPFmtpLine: "profile-id=2", + RTCPFeedback: VideoRTCPFeedback, + }, + }, + { + PayloadType: 113, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + SDPFmtpLine: "level-id=93;profile-id=1;tier-flag=0;tx-mode=SRST", + RTCPFeedback: VideoRTCPFeedback, + }, + }, +} + +var AudioCodecs = []webrtc.RTPCodecParameters{ + { + PayloadType: 111, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48_000, + Channels: 2, + SDPFmtpLine: "minptime=10;useinbandfec=1", + RTCPFeedback: nil, + }, + }, +} + +func GetDefaultTracks(streamKey string) (audioTrack *TrackMultiCodec, videoTrack *TrackMultiCodec) { + audioTrack = CreateTrackMultiCodec( + "audio", + "pion", + streamKey, + webrtc.RTPCodecTypeAudio, + 0) + + videoTrack = CreateTrackMultiCodec( + "video", + "pion", + streamKey, + webrtc.RTPCodecTypeVideo, + 0) + + return audioTrack, videoTrack +} + +func GetAudioTrackCodec(codec string) TrackCodeType { + lowerCase := strings.ToLower(codec) + + switch { + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeOpus)): + return AudioTrackCodecOpus + } + + return 0 +} + +func GetVideoTrackCodec(codec string) TrackCodeType { + lowerCase := strings.ToLower(codec) + + switch { + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeH264)): + return VideoTrackCodecH264 + + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeVP8)): + return VideoTrackCodecVP8 + + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeVP9)): + return VideoTrackCodecVP9 + + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeAV1)): + return VideoTrackCodecAV1 + + case strings.Contains(lowerCase, strings.ToLower(webrtc.MimeTypeH265)): + return VideoTrackCodecH265 + } + + return 0 +} diff --git a/internal/webrtc/codecs/mainengine_register.go b/internal/webrtc/codecs/mainengine_register.go new file mode 100644 index 00000000..0d5d4baf --- /dev/null +++ b/internal/webrtc/codecs/mainengine_register.go @@ -0,0 +1,51 @@ +package codecs + +import ( + "log" + + "github.com/pion/webrtc/v4" +) + +func RegisterCodecs(mediaEngine *webrtc.MediaEngine) { + if err := registerVideoCodecs(mediaEngine); err != nil { + log.Fatal(err) + } + + if err := registerAudioCodecs(mediaEngine); err != nil { + log.Fatal(err) + } +} + +func registerAudioCodecs(mediaEngine *webrtc.MediaEngine) []error { + errors := []error{} + for _, codec := range AudioCodecs { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil { + log.Println("Error registering codec", codec.MimeType) + errors = append(errors, err) + } + } + + if len(errors) != 0 { + log.Println("Errors registering codecs", len(errors)) + return errors + } + + return nil +} + +func registerVideoCodecs(mediaEngine *webrtc.MediaEngine) []error { + errors := []error{} + for _, codec := range VideoCodecs { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + log.Println("Error registering codec", codec.MimeType) + errors = append(errors, err) + } + } + + if len(errors) != 0 { + log.Println("Errors registering codecs", len(errors)) + return errors + } + + return nil +} diff --git a/internal/webrtc/codecs/trackMultiCodec.go b/internal/webrtc/codecs/trackMultiCodec.go new file mode 100644 index 00000000..fa369f53 --- /dev/null +++ b/internal/webrtc/codecs/trackMultiCodec.go @@ -0,0 +1,163 @@ +package codecs + +import ( + "log" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +type TrackPacket struct { + Layer string + Packet *rtp.Packet + TimeDiff int64 + SequenceDiff int + Codec TrackCodeType + IsKeyframe bool +} + +type TrackMultiCodec struct { + id string + rid string + streamId string + kind webrtc.RTPCodecType + codec TrackCodeType + errorCount int + + ssrc webrtc.SSRC + writeStream webrtc.TrackLocalWriter + + payloadTypeH264 uint8 + payloadTypeH265 uint8 + payloadTypeVP8 uint8 + payloadTypeVP9 uint8 + payloadTypeAV1 uint8 + payloadTypeOpus uint8 + + currentPayloadType uint8 +} + +func (track *TrackMultiCodec) ID() string { return track.id } +func (track *TrackMultiCodec) RID() string { return track.rid } +func (track *TrackMultiCodec) StreamID() string { return track.streamId } +func (track *TrackMultiCodec) Kind() webrtc.RTPCodecType { return track.kind } + +func CreateTrackMultiCodec(id string, rid string, streamId string, kind webrtc.RTPCodecType, codec TrackCodeType) *TrackMultiCodec { + return &TrackMultiCodec{ + id: id, + rid: rid, + streamId: streamId, + kind: kind, + codec: codec, + } +} + +func (track *TrackMultiCodec) Bind(ctx webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) { + track.ssrc = ctx.SSRC() + track.writeStream = ctx.WriteStream() + + var videoCodecParameters webrtc.RTPCodecParameters + codecParameters := ctx.CodecParameters() + for parameters := range codecParameters { + switch GetAudioTrackCodec(codecParameters[parameters].MimeType) { + case AudioTrackCodecOpus: + track.payloadTypeOpus = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeOpus + } + + if track.payloadTypeOpus != 0 { + log.Println("WhipSession.TrackMultiCodec: Binding AudioTrack Type for", track.streamId, "-", track.currentPayloadType) + + track.kind = webrtc.RTPCodecTypeAudio + return webrtc.RTPCodecParameters{ + PayloadType: codecParameters[parameters].PayloadType, + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: codecParameters[parameters].MimeType, + RTCPFeedback: codecParameters[parameters].RTCPFeedback, + ClockRate: codecParameters[parameters].ClockRate, + SDPFmtpLine: codecParameters[parameters].SDPFmtpLine, + }, + }, nil + } + + switch GetVideoTrackCodec(codecParameters[parameters].MimeType) { + case VideoTrackCodecH264: + track.payloadTypeH264 = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeH264 + videoCodecParameters = codecParameters[parameters] + + case VideoTrackCodecH265: + track.payloadTypeH265 = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeH265 + videoCodecParameters = codecParameters[parameters] + + case VideoTrackCodecVP8: + track.payloadTypeVP8 = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeVP8 + videoCodecParameters = codecParameters[parameters] + + case VideoTrackCodecVP9: + track.payloadTypeVP9 = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeVP9 + videoCodecParameters = codecParameters[parameters] + + case VideoTrackCodecAV1: + track.payloadTypeAV1 = uint8(codecParameters[parameters].PayloadType) + track.currentPayloadType = track.payloadTypeAV1 + videoCodecParameters = codecParameters[parameters] + } + } + + log.Println("WhepSession.TrackMultiCodec: Binding VideoTrack Type for", track.streamId, "-", track.currentPayloadType) + track.kind = webrtc.RTPCodecTypeVideo + return webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: videoCodecParameters.MimeType, + RTCPFeedback: videoCodecParameters.RTCPFeedback, + ClockRate: videoCodecParameters.ClockRate, + SDPFmtpLine: videoCodecParameters.SDPFmtpLine, + Channels: videoCodecParameters.Channels, + }, + }, nil +} + +func (track *TrackMultiCodec) Unbind(context webrtc.TrackLocalContext) error { + return nil +} + +func (track *TrackMultiCodec) WriteRTP(packet *rtp.Packet, codec TrackCodeType) error { + packet.SSRC = uint32(track.ssrc) + + if codec != track.codec { + log.Println("WhepSession.TrackMultiCodec.WriteRTP: Setting Codec on", track.streamId, "(", track.RID(), ")", "from", track.codec, "to", codec) + track.codec = codec + + switch track.codec { + case VideoTrackCodecH264: + track.currentPayloadType = track.payloadTypeH264 + case VideoTrackCodecH265: + track.currentPayloadType = track.payloadTypeH265 + case VideoTrackCodecVP8: + track.currentPayloadType = track.payloadTypeVP8 + case VideoTrackCodecVP9: + track.currentPayloadType = track.payloadTypeVP9 + case VideoTrackCodecAV1: + track.currentPayloadType = track.payloadTypeAV1 + case AudioTrackCodecOpus: + track.currentPayloadType = track.payloadTypeOpus + } + } + + packet.PayloadType = track.currentPayloadType + + if _, err := track.writeStream.WriteRTP(&packet.Header, packet.Payload); err != nil { + track.errorCount += 1 + + if track.errorCount%50 == 0 { + log.Println("WhipSession.TrackMultiCodec.WriteRTP.Error(", track.errorCount, ")", err) + return err + } + } + + return nil +} diff --git a/internal/webrtc/interceptors/interceptors.go b/internal/webrtc/interceptors/interceptors.go new file mode 100644 index 00000000..493149cd --- /dev/null +++ b/internal/webrtc/interceptors/interceptors.go @@ -0,0 +1,16 @@ +package interceptors + +import ( + "github.com/pion/interceptor" + "github.com/pion/webrtc/v4" + "log" +) + +func GetRegistry(mediaEngine *webrtc.MediaEngine) interceptor.Registry { + interceptorRegistry := &interceptor.Registry{} + if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { + log.Fatal(err) + } + + return *interceptorRegistry +} diff --git a/internal/webrtc/keyframe_detector.go b/internal/webrtc/keyframe_detector.go deleted file mode 100644 index a45baf51..00000000 --- a/internal/webrtc/keyframe_detector.go +++ /dev/null @@ -1,26 +0,0 @@ -package webrtc - -import ( - "github.com/pion/rtp" -) - -const ( - naluTypeBitmask = 0x1F - - idrNALUType = 5 - spsNALUType = 7 - ppsNALUType = 8 -) - -func isKeyframe(pkt *rtp.Packet, codec videoTrackCodec, depacketizer rtp.Depacketizer) bool { - if codec == videoTrackCodecH264 { - nalu, err := depacketizer.Unmarshal(pkt.Payload) - if err != nil || len(nalu) < 6 { - return false - } - - firstNaluType := nalu[4] & naluTypeBitmask - return firstNaluType == idrNALUType || firstNaluType == spsNALUType || firstNaluType == ppsNALUType - } - return true -} diff --git a/internal/webrtc/peerconnection/get_configuration.go b/internal/webrtc/peerconnection/get_configuration.go new file mode 100644 index 00000000..cbd0017e --- /dev/null +++ b/internal/webrtc/peerconnection/get_configuration.go @@ -0,0 +1,49 @@ +package peerconnection + +import ( + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/pion/webrtc/v4" +) + +func GetPeerConnectionConfig() webrtc.Configuration { + config := webrtc.Configuration{} + if stunServers := os.Getenv(environment.STUN_SERVERS_INTERNAL); stunServers != "" { + for stunServer := range strings.SplitSeq(stunServers, "|") { + config.ICEServers = append(config.ICEServers, webrtc.ICEServer{ + URLs: []string{"stun:" + stunServer}, + }) + } + } else if stunServers := os.Getenv(environment.STUN_SERVERS); stunServers != "" { + for stunServer := range strings.SplitSeq(stunServers, "|") { + config.ICEServers = append(config.ICEServers, webrtc.ICEServer{ + URLs: []string{"stun:" + stunServer}, + }) + } + } + + username, credential := authorization.GetTURNCredentials() + + if turnServers := os.Getenv(environment.TURN_SERVERS_INTERNAL); turnServers != "" { + for turnServer := range strings.SplitSeq(turnServers, "|") { + config.ICEServers = append(config.ICEServers, webrtc.ICEServer{ + URLs: []string{"turn:" + turnServer}, + Username: username, + Credential: credential, + }) + } + } else if turnServers := os.Getenv(environment.TURN_SERVERS); turnServers != "" { + for turnServer := range strings.SplitSeq(turnServers, "|") { + config.ICEServers = append(config.ICEServers, webrtc.ICEServer{ + URLs: []string{"turn:" + turnServer}, + Username: username, + Credential: credential, + }) + } + } + + return config +} diff --git a/internal/webrtc/peerconnection/peerconnection_functions.go b/internal/webrtc/peerconnection/peerconnection_functions.go new file mode 100644 index 00000000..99f9712b --- /dev/null +++ b/internal/webrtc/peerconnection/peerconnection_functions.go @@ -0,0 +1,53 @@ +package peerconnection + +import ( + "log" + + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" + "github.com/pion/webrtc/v4" +) + +type CreateWhipPeerConnectionResult struct { + PeerConnection *webrtc.PeerConnection + Error error +} + +func CreateWhepPeerConnection() (*webrtc.PeerConnection, error) { + return manager.ApiWhep.NewPeerConnection(GetPeerConnectionConfig()) +} + +func CreateWhipPeerConnection(offer string) (*webrtc.PeerConnection, error) { + log.Println("CreateWhipPeerConnection.CreateWhipPeerConnection") + + peerConnection, err := manager.ApiWhip.NewPeerConnection(GetPeerConnectionConfig()) + if err != nil { + return nil, err + } + + // Setup PeerConnection RemoteDescription + sessionDescription := webrtc.SessionDescription{ + SDP: string(offer), + Type: webrtc.SDPTypeOffer, + } + + if err := peerConnection.SetRemoteDescription(sessionDescription); err != nil { + return nil, err + } + + gatheringCompleteResult := webrtc.GatheringCompletePromise(peerConnection) + + answer, err := peerConnection.CreateAnswer(nil) + if err != nil { + return nil, err + } + + if err := peerConnection.SetLocalDescription(answer); err != nil { + return nil, err + } + + // Await gathering trickle + <-gatheringCompleteResult + log.Println("PeerConnection.CreateWhipPeerConnection.GatheringCompleteResult") + + return peerConnection, nil +} diff --git a/internal/webrtc/sessions/manager/manager.go b/internal/webrtc/sessions/manager/manager.go new file mode 100644 index 00000000..f53052b6 --- /dev/null +++ b/internal/webrtc/sessions/manager/manager.go @@ -0,0 +1,213 @@ +package manager + +import ( + "context" + "log" + "maps" + "time" + + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/session" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whip" +) + +// Prepare the Whip Session Manager +func (manager *SessionManager) Setup() { + log.Println("WhipSessionManager.Setup") + + manager.sessions = make(map[string]*session.Session) +} + +// Add new session +func (manager *SessionManager) addSession(profile authorization.PublicProfile) (s *session.Session, err error) { + log.Println("SessionManager.AddWhipSession") + activeContext, activeContextCancel := context.WithCancel(context.Background()) + + s = &session.Session{ + + StreamKey: profile.StreamKey, + IsPublic: profile.IsPublic, + MOTD: profile.MOTD, + StreamStart: time.Now(), + + ActiveContext: activeContext, + ActiveContextCancel: activeContextCancel, + + WhepSessions: map[string]*whep.WhepSession{}, + } + + s.HasHost.Store(true) + manager.sessionsLock.Lock() + manager.sessions[profile.StreamKey] = s + manager.sessionsLock.Unlock() + + go s.Snapshot() + go func() { + <-activeContext.Done() + log.Println("SessionManager.Session.Done") + + manager.sessionsLock.Lock() + delete(manager.sessions, profile.StreamKey) + manager.sessionsLock.Unlock() + + }() + + return s, nil +} + +// Get the stream requested, or create it, and add it to the sessions context +func (manager *SessionManager) GetOrAddSession(profile authorization.PublicProfile, isWhip bool) (session *session.Session, err error) { + session, ok := manager.GetSessionById(profile.StreamKey) + + if !ok { + log.Println("SessionManager.GetOrAddStream: Adding", profile.StreamKey) + session, err = manager.addSession(profile) + } else if isWhip { + log.Println("SessionManager.GetOrAddStream: Updating", profile.StreamKey) + session.UpdateStreamStatus(profile) + } + + return session, err +} + +// Get Session by id +func (manager *SessionManager) GetSessionById(streamKey string) (session *session.Session, foundSession bool) { + log.Println("SessionManager.GetSessionById", streamKey) + + manager.sessionsLock.RLock() + defer manager.sessionsLock.RUnlock() + + for _, session := range manager.sessions { + if streamKey == session.StreamKey { + return session, true + } + } + + return nil, false +} + +// Gets the current state of all sessions +func (manager *SessionManager) GetSessionStates(includePrivateStreams bool) (result []session.StreamSessionDto) { + log.Println("SessionManager.GetSessionStates: IsAdmin", includePrivateStreams) + manager.sessionsLock.RLock() + copiedSessions := make(map[string]*session.Session) + maps.Copy(copiedSessions, manager.sessions) + manager.sessionsLock.RUnlock() + + for _, s := range copiedSessions { + s.StatusLock.RLock() + + if !includePrivateStreams && !s.IsPublic { + s.StatusLock.RUnlock() + continue + } + + streamSession := session.StreamSessionDto{ + StreamKey: s.StreamKey, + StreamStart: s.StreamStart, + IsPublic: s.IsPublic, + MOTD: s.MOTD, + Sessions: []whep.WhepSessionStateDto{}, + VideoTracks: []session.VideoTrackState{}, + AudioTracks: []session.AudioTrackState{}, + } + + s.StatusLock.RUnlock() + + if s.Host != nil { + s.Host.TracksLock.RLock() + + for _, audioTrack := range s.Host.AudioTracks { + streamSession.AudioTracks = append( + streamSession.AudioTracks, + session.AudioTrackState{ + Rid: audioTrack.Rid, + PacketsReceived: audioTrack.PacketsReceived.Load(), + PacketsDropped: audioTrack.PacketsDropped.Load(), + }) + } + + for _, videoTrack := range s.Host.VideoTracks { + var lastKeyFrame time.Time + if value, ok := videoTrack.LastKeyFrame.Load().(time.Time); ok { + lastKeyFrame = value + } + + streamSession.VideoTracks = append( + streamSession.VideoTracks, + session.VideoTrackState{ + Rid: videoTrack.Rid, + Bitrate: videoTrack.Bitrate.Load(), + PacketsReceived: videoTrack.PacketsReceived.Load(), + PacketsDropped: videoTrack.PacketsDropped.Load(), + LastKeyframe: lastKeyFrame, + }) + } + + s.Host.TracksLock.RUnlock() + } + + s.WhepSessionsLock.RLock() + for _, whep := range s.WhepSessions { + if !whep.IsSessionClosed.Load() { + streamSession.Sessions = append(streamSession.Sessions, whep.GetWhepSessionStatus()) + } + } + s.WhepSessionsLock.RUnlock() + + result = append(result, streamSession) + } + + return +} + +// Update the provided session information +func (manager *SessionManager) UpdateProfile(profile *authorization.PersonalProfile) { + log.Println("WhipSessionManager.UpdateProfile") + manager.sessionsLock.RLock() + whipSession, ok := manager.sessions[profile.StreamKey] + manager.sessionsLock.RUnlock() + + if ok { + whipSession.StatusLock.Lock() + whipSession.MOTD = profile.MOTD + whipSession.IsPublic = profile.IsPublic + whipSession.StatusLock.Unlock() + } +} + +// Get Session by id +func (manager *SessionManager) GetWhepSessionById(sessionId string) (whep *whep.WhepSession, foundSession bool) { + + manager.sessionsLock.RLock() + defer manager.sessionsLock.RUnlock() + + for _, session := range manager.sessions { + session.WhepSessionsLock.RLock() + defer session.WhepSessionsLock.RUnlock() + if whep, ok := session.WhepSessions[sessionId]; ok { + return whep, true + } + } + + return nil, false +} + +func (manager *SessionManager) GetHostSessionById(sessionId string) (host *whip.WhipSession, foundSession bool) { + manager.sessionsLock.RLock() + defer manager.sessionsLock.RUnlock() + + for _, session := range manager.sessions { + + if session.Host == nil { + return nil, false + } + + if sessionId == session.Host.Id { + return session.Host, true + } + } + + return nil, false +} diff --git a/internal/webrtc/sessions/manager/type.go b/internal/webrtc/sessions/manager/type.go new file mode 100644 index 00000000..f685a154 --- /dev/null +++ b/internal/webrtc/sessions/manager/type.go @@ -0,0 +1,20 @@ +package manager + +import ( + "sync" + + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/session" + "github.com/pion/webrtc/v4" +) + +var ( + SessionsManager *SessionManager + + ApiWhip *webrtc.API + ApiWhep *webrtc.API +) + +type SessionManager struct { + sessionsLock sync.RWMutex + sessions map[string]*session.Session +} diff --git a/internal/webrtc/sessions/session/dtos.go b/internal/webrtc/sessions/session/dtos.go new file mode 100644 index 00000000..e89d2fd5 --- /dev/null +++ b/internal/webrtc/sessions/session/dtos.go @@ -0,0 +1,65 @@ +package session + +import ( + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "time" +) + +// Status for an individual streaming session +type WhipSessionStatus struct { + StreamKey string `json:"streamKey"` + MOTD string `json:"motd"` + ViewerCount int `json:"viewers"` + IsOnline bool `json:"isOnline"` + StreamStart time.Time `json:"streamStart"` +} + +// Information for a whip session +type WhepSessionState struct { + Id string `json:"id"` + + AudioLayerCurrent string `json:"audioLayerCurrent"` + AudioTimestamp uint32 `json:"audioTimestamp"` + AudioPacketsWritten uint64 `json:"audioPacketsWritten"` + AudioSequenceNumber uint64 `json:"audioSequenceNumber"` + + VideoLayerCurrent string `json:"videoLayerCurrent"` + VideoTimestamp uint32 `json:"videoTimestamp"` + VideoPacketsWritten uint64 `json:"videoPacketsWritten"` + VideoSequenceNumber uint64 `json:"videoSequenceNumber"` +} + +// Status for an individual streaming session +type StreamStatusDto struct { + StreamKey string `json:"streamKey"` + MOTD string `json:"motd"` + ViewerCount int `json:"viewers"` + IsOnline bool `json:"isOnline"` +} + +// Information for a whip session +type StreamSessionDto struct { + StreamKey string `json:"streamKey"` + IsPublic bool `json:"isPublic"` + MOTD string `json:"motd"` + StreamStart time.Time `json:"streamStart"` + + AudioTracks []AudioTrackState `json:"audioTracks"` + VideoTracks []VideoTrackState `json:"videoTracks"` + + Sessions []whep.WhepSessionStateDto `json:"sessions"` +} + +type AudioTrackState struct { + Rid string `json:"rid"` + PacketsReceived uint64 `json:"packetsReceived"` + PacketsDropped uint64 `json:"packetsDropped"` +} + +type VideoTrackState struct { + Rid string `json:"rid"` + Bitrate uint64 `json:"bitrate"` + PacketsReceived uint64 `json:"packetsReceived"` + PacketsDropped uint64 `json:"packetsDropped"` + LastKeyframe time.Time `json:"lastKeyframe"` +} diff --git a/internal/webrtc/sessions/session/routines.go b/internal/webrtc/sessions/session/routines.go new file mode 100644 index 00000000..47653fa0 --- /dev/null +++ b/internal/webrtc/sessions/session/routines.go @@ -0,0 +1,176 @@ +package session + +import ( + "log" + "time" + + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whip" + "github.com/pion/rtcp" + "github.com/pion/webrtc/v4" +) + +//TODO: Might not neccessary +// Triggered when a host is disconnected +// func (session *Session) handleHostDisconnect() { +// log.Println("Session.Host.Disconnected", session.StreamKey) +// +// // WHIP host offline +// if session.Host != nil { +// session.Host.RemovePeerConnection() +// session.Host.RemoveTracks() +// } +// session.handleAnnounceOffline() +// +// } + +// When WHEP is established, send initial messages to client +func (session *Session) handleWhepConnection(whipSession *whip.WhipSession, whepSession *whep.WhepSession) { + log.Println("Session.WhepSession.Connected:", session.StreamKey) + whepSession.SseEventsChannel <- session.GetSessionStatsEvent() + whepSession.SseEventsChannel <- whipSession.GetAvailableLayersEvent() + + <-whepSession.ActiveContext.Done() + + log.Println("Session.WhepSession.Disconnected:", session.StreamKey, " - ", whepSession.SessionId) + session.removeWhep(whepSession.SessionId) +} + +// TODO: Implement correctly +// Handle WHEP Layer changes and trigger keyframe from WHIP +// func (session *Session) handleWhepLayerChange(whipSession *whip.WhipSession, whepSession *whep.WhepSession) { +// for { +// select { +// case <-whipSession.ActiveContext.Done(): +// return +// default: +// if whepSession.IsSessionClosed.Load() { +// return +// } else if session.HasHost.Load() && whepSession.IsWaitingForKeyframe.Load() { +// log.Println("WhepSession.PictureLossIndication.IsWaitingForKeyframe") +// select { +// case whipSession.PacketLossIndicationChannel <- true: +// default: +// log.Println("WhepSession.PictureLossIndication.Channel: Full channel, skipping") +// } +// } +// } +// +// time.Sleep(500 * time.Millisecond) +// } +// } + +func (session *Session) handleWhepVideoRtcpSender(rtcpSender *webrtc.RTPSender) { + for { + rtcpPackets, _, rtcpErr := rtcpSender.ReadRTCP() + if rtcpErr != nil { + log.Println("WhepSession.ReadRTCP.Error:", rtcpErr) + return + } + + if session.HasHost.Load() { + for _, packet := range rtcpPackets { + if _, isPLI := packet.(*rtcp.PictureLossIndication); isPLI { + select { + case session.Host.PacketLossIndicationChannel <- true: + default: + } + } + } + } + } +} + +// Handle picture loss indication packages +func (session *Session) handleWhepChannels(whepSession *whep.WhepSession) { + for { + select { + case <-whepSession.ActiveContext.Done(): + return + + case <-whepSession.ConnectionChannel: + select { + case session.Host.PacketLossIndicationChannel <- true: + default: + } + } + } +} + +// - Initializes by announcing stream start to potentially awaiting clients +// - Announces layers changes to clients when layers are added or removed from the session +// - Triggers a status update every 5 seconds to send to all listening WHEP sessions +func (session *Session) hostStatusLoop() { + log.Println("Session.Host.HostStatusLoop") + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + host := session.Host + if host == nil { + if session.isEmpty() { + session.close() + return + } + + time.Sleep(5 * time.Second) + continue + } + + select { + + case <-host.ActiveContext.Done(): + session.RemoveHost() + + if session.isEmpty() { + session.close() + } + return + + // Send status every 5 seconds + case <-ticker.C: + if session.isEmpty() { + session.close() + } else if session.Host != nil { + + status := session.GetSessionStatsEvent() + session.WhepSessionsLock.RLock() + for _, whep := range session.WhepSessions { + whep.SseEventsChannel <- status + } + session.WhepSessionsLock.RUnlock() + + } + } + } +} + +// Start a routing that takes snapshots of the current whep sessions in the whip session. +func (session *Session) Snapshot() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-session.ActiveContext.Done(): + if session.Host != nil { + session.Host.WhepSessionsSnapshot.Store(make(map[string]*whep.WhepSession)) + } + return + case <-ticker.C: + if session.Host != nil { + session.WhepSessionsLock.RLock() + snapshot := make(map[string]*whep.WhepSession, len(session.WhepSessions)) + + for _, whepSession := range session.WhepSessions { + if !whepSession.IsSessionClosed.Load() { + snapshot[whepSession.SessionId] = whepSession + } + } + session.WhepSessionsLock.RUnlock() + + session.Host.WhepSessionsSnapshot.Store(snapshot) + } + } + } +} diff --git a/internal/webrtc/sessions/session/session.go b/internal/webrtc/sessions/session/session.go new file mode 100644 index 00000000..4edb7367 --- /dev/null +++ b/internal/webrtc/sessions/session/session.go @@ -0,0 +1,253 @@ +package session + +import ( + "context" + "fmt" + "log" + + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whip" + "github.com/google/uuid" + "github.com/pion/webrtc/v4" +) + +// Get Whip stream by stream key +func (session *Session) GetHost(streamKey string) (host *whip.WhipSession, foundSession bool) { + log.Println("Session.GetHost") + + if session.Host == nil { + return nil, false + } + + session.HostLock.RLock() + host = session.Host + session.HostLock.RUnlock() + + return host, true +} + +// Find Whep session by session id +func (session *Session) GetWhepStream(sessionId string) (whepSession *whep.WhepSession, foundSession bool) { + log.Println("WhipSessionManager.GetWhepStream") + + session.WhepSessionsLock.RLock() + defer session.WhepSessionsLock.RUnlock() + + if whepSession, ok := session.WhepSessions[sessionId]; ok { + return whepSession, true + } + + return nil, false +} + +func (session *Session) UpdateStreamStatus(profile authorization.PublicProfile) { + session.StatusLock.Lock() + + session.HasHost.Store(true) + session.MOTD = profile.MOTD + session.IsPublic = profile.IsPublic + + session.StatusLock.Unlock() +} + +// Add WHEP session to existing WHIP session +func (session *Session) AddWhep(whepSessionId string, peerConnection *webrtc.PeerConnection, audioTrack *codecs.TrackMultiCodec, videoTrack *codecs.TrackMultiCodec, videoRtcpSender *webrtc.RTPSender) (err error) { + log.Println("WhipSessionManager.WhipSession.AddWhepSession") + + if session.Host == nil { + return fmt.Errorf("no host was found on the current session") + } + + whepSession := whep.CreateNewWhep( + whepSessionId, + audioTrack, + session.Host.GetHighestPrioritizedAudioTrack(), + videoTrack, + session.Host.GetHighestPrioritizedVideoTrack(), + peerConnection) + + whepSession.RegisterWhepHandlers(peerConnection) + + session.WhepSessionsLock.Lock() + session.WhepSessions[whepSessionId] = whepSession + session.WhepSessionsLock.Unlock() + + go session.handleWhepConnection(session.Host, whepSession) + go session.handleWhepChannels(whepSession) + go session.handleWhepVideoRtcpSender(videoRtcpSender) + + // TODO: Implement + // go session.handleWhepLayerChange(session.Host, whepSession) + + return nil +} + +// Add host +func (session *Session) AddHost(peerConnection *webrtc.PeerConnection) (err error) { + log.Println("Session.AddHost") + + session.HostLock.Lock() + + if session.Host != nil && session.Host.PeerConnection.ConnectionState() == webrtc.PeerConnectionStateClosed { + if session.ActiveContext.Err() != nil { + session.Host = nil + } else { + session.HostLock.Unlock() + return fmt.Errorf("session already has a host") + } + } + + activeContext, activeContextCancel := context.WithCancel(context.Background()) + + session.Host = &whip.WhipSession{ + Id: uuid.New().String(), + AudioTracks: make(map[string]*whip.AudioTrack), + VideoTracks: make(map[string]*whip.VideoTrack), + PacketLossIndicationChannel: make(chan bool, 50), + OnTrackChangeChannel: make(chan struct{}, 50), + EventsChannel: make(chan any, 50), + + ActiveContext: activeContext, + ActiveContextCancel: activeContextCancel, + } + + session.Host.AddPeerConnection(peerConnection, session.StreamKey) + session.HostLock.Unlock() + + go session.hostStatusLoop() + + return nil +} + +func (session *Session) RemoveHost() { + + if session.Host == nil { + log.Println("Session.RemoveHost", session.StreamKey, "- No host to remove") + return + } + + log.Println("Session.RemoveHost", session.StreamKey) + + session.Host.ActiveContextCancel() + session.Host.RemovePeerConnection() + session.Host.RemoveTracks() + + session.HostLock.Lock() + session.Host = nil + session.HostLock.Unlock() +} + +// Remove Whep session from Whip session +// In case the Whip session does not have a host, and no more whep sessions, it will +// be remove from the manager. +func (session *Session) removeWhep(whepSessionId string) { + log.Println("Session.RemoveWhepSession:", session.StreamKey, " - ", whepSessionId) + + session.WhepSessionsLock.Lock() + session.WhepSessions[whepSessionId].Close() + delete(session.WhepSessions, whepSessionId) + session.WhepSessionsLock.Unlock() + + if session.isEmpty() { + session.close() + } +} + +func (session *Session) RemoveAllWhep(whipSession *whip.WhipSession, whepSessionId string) { + log.Println("Session.RemoveWhepSession:", session.StreamKey, " - ", whepSessionId) + + for whepSessionId := range session.WhepSessions { + session.removeWhep(whepSessionId) + } +} + +// Remove all Hosts and clients before closing down session +func (session *Session) close() { + + session.WhepSessionsLock.Lock() + for _, whep := range session.WhepSessions { + whep.Close() + } + session.WhepSessions = make(map[string]*whep.WhepSession) + session.WhepSessionsLock.Unlock() + + session.RemoveHost() + + session.ActiveContextCancel() +} + +// Returns true is no WHIP tracks are present, and no WHEP sessions are waiting for incoming streams +func (session *Session) isEmpty() bool { + if session.hasWhepSessions() { + log.Println("Session.IsEmpty.HasWhepSessions (false):", session.StreamKey) + return false + } + + if session.isStreaming() { + log.Println("Session.IsEmpty.IsActive (false):", session.StreamKey) + return false + } + + log.Println("Session.IsEmpty (true):", session.StreamKey) + return true +} + +// Returns true if any tracks are available for the session +func (session *Session) isStreaming() bool { + + if session.Host == nil { + return false + } + + session.Host.TracksLock.RLock() + + if len(session.Host.AudioTracks) != 0 { + log.Println("Session.IsActive.AudioTracks", len(session.Host.AudioTracks)) + session.Host.TracksLock.RUnlock() + return true + } + if len(session.Host.VideoTracks) != 0 { + log.Println("Session.IsActive.VideoTracks", len(session.Host.VideoTracks)) + session.Host.TracksLock.RUnlock() + return true + } + + session.Host.TracksLock.RUnlock() + return false +} + +func (session *Session) hasWhepSessions() bool { + session.WhepSessionsLock.RLock() + log.Println("Session.HasWhepSessions:", len(session.WhepSessions)) + + if len(session.WhepSessions) == 0 { + session.WhepSessionsLock.RUnlock() + return false + } + + session.WhepSessionsLock.RUnlock() + return true +} + +// Get the status of the current session +func (session *Session) GetStreamStatus() (status WhipSessionStatus) { + session.WhepSessionsLock.RLock() + whepSessionsCount := len(session.WhepSessions) + session.WhepSessionsLock.RUnlock() + + session.StatusLock.RLock() + + status = WhipSessionStatus{ + StreamKey: session.StreamKey, + MOTD: session.MOTD, + ViewerCount: whepSessionsCount, + IsOnline: session.HasHost.Load(), + StreamStart: session.StreamStart, + } + + session.StatusLock.RUnlock() + + return +} diff --git a/internal/webrtc/sessions/session/sse.go b/internal/webrtc/sessions/session/sse.go new file mode 100644 index 00000000..b05ac9c5 --- /dev/null +++ b/internal/webrtc/sessions/session/sse.go @@ -0,0 +1,47 @@ +package session + +import ( + "log" + "maps" + + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/glimesh/broadcast-box/internal/webrtc/utils" +) + +// Get SSE String with status about the current session +func (session *Session) GetSessionStatsEvent() string { + + status, err := utils.ToJsonString(session.GetStreamStatus()) + if err != nil { + log.Println("GetSessionStatsJsonString Error:", err) + return "" + } + + return "event: status\ndata: " + status + "\n\n" +} + +// Send out an event to all WHEP sessions to notify that available layers has changed +func (session *Session) AnnounceStreamStartToWhepClients() { + log.Println("Session.AnnounceStreamStartToWhepClients:", session.StreamKey) + + // Lock, copy session data, then unlock + session.WhepSessionsLock.RLock() + whepSessionsCopy := make(map[string]*whep.WhepSession) + maps.Copy(whepSessionsCopy, session.WhepSessions) + session.WhepSessionsLock.RUnlock() + + // Generate layer info outside lock + streamStartMessage := "event: streamStart\ndata:\n" + + // Send to each WHEP session + for _, whepSession := range whepSessionsCopy { + if !whepSession.IsSessionClosed.Load() { + // Announce to frontend client + select { + case whepSession.SseEventsChannel <- streamStartMessage: + default: + log.Println("WhepSession.AnnounceStreamStartToWhepClients: Channel full, skipping update (SessionId:", whepSession.SessionId, ")") + } + } + } +} diff --git a/internal/webrtc/sessions/session/type.go b/internal/webrtc/sessions/session/type.go new file mode 100644 index 00000000..8859f46d --- /dev/null +++ b/internal/webrtc/sessions/session/type.go @@ -0,0 +1,35 @@ +package session + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whip" +) + +type Session struct { + + // Protects StreamKey, SessionId, MOTD, HasHost, IsPublic + StatusLock sync.RWMutex + StreamKey string + + SessionId string + MOTD string + HasHost atomic.Bool + IsPublic bool + StreamStart time.Time + + HostLock sync.RWMutex + Host *whip.WhipSession + + // Context + ActiveContext context.Context + ActiveContextCancel func() + + // Protects WhepSessions + WhepSessionsLock sync.RWMutex + WhepSessions map[string]*whep.WhepSession +} diff --git a/internal/webrtc/sessions/whep/dtos.go b/internal/webrtc/sessions/whep/dtos.go new file mode 100644 index 00000000..16447529 --- /dev/null +++ b/internal/webrtc/sessions/whep/dtos.go @@ -0,0 +1,17 @@ +package whep + +type WhepSessionStateDto struct { + Id string `json:"id"` + + AudioLayerCurrent string `json:"audioLayerCurrent"` + AudioTimestamp uint32 `json:"audioTimestamp"` + AudioPacketsWritten uint64 `json:"audioPacketsWritten"` + AudioSequenceNumber uint64 `json:"audioSequenceNumber"` + + VideoLayerCurrent string `json:"videoLayerCurrent"` + VideoTimestamp uint32 `json:"videoTimestamp"` + VideoBitrate uint64 `json:"videoBitrate"` + VideoPacketsDropped uint64 `json:"videoPacketsDropped"` + VideoPacketsWritten uint64 `json:"videoPacketsWritten"` + VideoSequenceNumber uint64 `json:"videoSequenceNumber"` +} diff --git a/internal/webrtc/sessions/whep/handlers.go b/internal/webrtc/sessions/whep/handlers.go new file mode 100644 index 00000000..0d8b0018 --- /dev/null +++ b/internal/webrtc/sessions/whep/handlers.go @@ -0,0 +1,33 @@ +package whep + +import ( + "log" + + "github.com/pion/webrtc/v4" +) + +func (whep *WhepSession) RegisterWhepHandlers(peerConnection *webrtc.PeerConnection) { + log.Println("WhepSession.RegisterHandlers") + + peerConnection.OnICEConnectionStateChange(onWhepICEConnectionStateChangeHandler(whep)) +} + +func onWhepICEConnectionStateChangeHandler(whep *WhepSession) func(webrtc.ICEConnectionState) { + return func(state webrtc.ICEConnectionState) { + log.Println("WhepSession.OnICEConnectionStateChange:", state) + switch state { + case + webrtc.ICEConnectionStateConnected: + select { + case whep.ConnectionChannel <- true: + default: + } + case + webrtc.ICEConnectionStateFailed, + webrtc.ICEConnectionStateClosed: + whep.Close() + default: + log.Println("WhepSession.OnICEConnectionStateChange.Default", state) + } + } +} diff --git a/internal/webrtc/sessions/whep/routines.go b/internal/webrtc/sessions/whep/routines.go new file mode 100644 index 00000000..b0a2abc6 --- /dev/null +++ b/internal/webrtc/sessions/whep/routines.go @@ -0,0 +1,50 @@ +package whep + +import ( + "log" + "os" + "strings" + "time" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +func (whepSession *WhepSession) handleCalculatedValues() { + ticker := time.NewTicker(1 * time.Second) + + lastBytesReceived := int(0) + + for { + select { + case <-whepSession.ActiveContext.Done(): + log.Println("WhepSession.HandleCalculatedValues.Close") + return + case <-ticker.C: + whepSession.VideoBitrate.Store(uint64(whepSession.VideoBytesWritten - lastBytesReceived)) + lastBytesReceived = whepSession.VideoBytesWritten + } + } +} + +func (whepSession *WhepSession) handleVideoChannel() { + experimentalWhepPacketDeepCloneToChannel := strings.EqualFold(os.Getenv(environment.WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS_TO_CHANNEL), "true") + + if !experimentalWhepPacketDeepCloneToChannel { + return + } + + for { + select { + case <-whepSession.ActiveContext.Done(): + log.Println("WhepSession.HandleVideoChannel.Close") + return + case packet, ok := <-whepSession.VideoChannel: + if !ok { + log.Println("WhepSession.HandleCalculatedValues.PacketError") + return + } + + whepSession.SendVideoPacket(packet) + } + } +} diff --git a/internal/webrtc/sessions/whep/sse.go b/internal/webrtc/sessions/whep/sse.go new file mode 100644 index 00000000..89d0fb3c --- /dev/null +++ b/internal/webrtc/sessions/whep/sse.go @@ -0,0 +1,17 @@ +package whep + +import ( + "log" + + "github.com/glimesh/broadcast-box/internal/webrtc/utils" +) + +func (whepSession *WhepSession) GetWhepSessionStatusEvent() string { + currentSessionStateJson, err := utils.ToJsonString(whepSession.GetWhepSessionStatus()) + if err != nil { + log.Println("WhepSession.GetWhepSessionStatus Error:", err) + return "" + } + + return "event: status\ndata: " + currentSessionStateJson + "\n\n" +} diff --git a/internal/webrtc/sessions/whep/type_whep.go b/internal/webrtc/sessions/whep/type_whep.go new file mode 100644 index 00000000..fa7e917b --- /dev/null +++ b/internal/webrtc/sessions/whep/type_whep.go @@ -0,0 +1,49 @@ +package whep + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/pion/webrtc/v4" +) + +type ( + WhepSession struct { + SessionId string + IsWaitingForKeyframe atomic.Bool + IsSessionClosed atomic.Bool + + WhipEventsChannel chan any + SseEventsChannel chan any + ConnectionChannel chan any + SessionClose sync.Once + ActiveContext context.Context + ActiveContextCancel func() + + PeerConnectionLock sync.RWMutex + PeerConnection *webrtc.PeerConnection + + // Protects VideoTrack, VideoTimestamp, VideoPacketsWritten, VideoSequenceNumber + VideoLock sync.RWMutex + VideoTrack *codecs.TrackMultiCodec + VideoTimestamp uint32 + VideoBitrate atomic.Uint64 + VideoBytesWritten int + VideoPacketsWritten uint64 + VideoPacketsDropped atomic.Uint64 + VideoSequenceNumber uint16 + VideoLayerCurrent atomic.Value + VideoChannel chan codecs.TrackPacket + + // Protects AudioTrack, AudioTimestamp, AudioPacketsWritten, AudioSequenceNumber + AudioLock sync.RWMutex + AudioTrack *codecs.TrackMultiCodec + AudioTimestamp uint32 + AudioPacketsWritten uint64 + AudioSequenceNumber uint16 + AudioLayerCurrent atomic.Value + AudioChannel chan codecs.TrackPacket + } +) diff --git a/internal/webrtc/sessions/whep/whep.go b/internal/webrtc/sessions/whep/whep.go new file mode 100644 index 00000000..9b433c12 --- /dev/null +++ b/internal/webrtc/sessions/whep/whep.go @@ -0,0 +1,131 @@ +package whep + +import ( + "context" + "log" + "os" + "strconv" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/pion/webrtc/v4" +) + +// Create and start a new WHEP session +func CreateNewWhep(whepSessionId string, audioTrack *codecs.TrackMultiCodec, audioLayer string, videoTrack *codecs.TrackMultiCodec, videoLayer string, peerConnection *webrtc.PeerConnection) (whepSession *WhepSession) { + log.Println("WhepSession.CreateNewWhep", whepSessionId) + audioChannelSizeStr := os.Getenv(environment.WHEP_SESSION_AUDIOCHANNEL_SIZE) + videoChannelSizeStr := os.Getenv(environment.WHEP_SESSION_VIDEOCHANNEL_SIZE) + + audioChannelSize, audioOk := strconv.Atoi(audioChannelSizeStr) + videoChannelSize, videoOk := strconv.Atoi(videoChannelSizeStr) + + if audioOk != nil || videoOk != nil { + log.Println("WhepSession.CreateNewWhep.AudioVideoChannelSize: Audio/Video channel sizes must be a valid number") + audioChannelSize = 50 + videoChannelSize = 50 + } + + activeContext, activeContextCancel := context.WithCancel(context.Background()) + whepSession = &WhepSession{ + SessionId: whepSessionId, + AudioTrack: audioTrack, + VideoTrack: videoTrack, + AudioTimestamp: 5000, + VideoTimestamp: 5000, + AudioChannel: make(chan codecs.TrackPacket, audioChannelSize), + VideoChannel: make(chan codecs.TrackPacket, videoChannelSize), + WhipEventsChannel: make(chan any, 100), + SseEventsChannel: make(chan any, 100), + ConnectionChannel: make(chan any, 100), + PeerConnection: peerConnection, + ActiveContext: activeContext, + ActiveContextCancel: activeContextCancel, + } + + log.Println("WhepSession.CreateNewWhep.AudioLayer", audioLayer) + log.Println("WhepSession.CreateNewWhep.VideoLayer", videoLayer) + whepSession.AudioLayerCurrent.Store(audioLayer) + whepSession.VideoLayerCurrent.Store(videoLayer) + whepSession.IsWaitingForKeyframe.Store(true) + whepSession.IsSessionClosed.Store(false) + + // Start WHEP go routines + go whepSession.handleCalculatedValues() + go whepSession.handleVideoChannel() + + return whepSession +} + +// Closes down the WHEP session completely +func (whepSession *WhepSession) Close() { + // Close WHEP channels + whepSession.SessionClose.Do(func() { + log.Println("WhepSession.Close") + whepSession.IsSessionClosed.Store(true) + + // Close PeerConnection + log.Println("WhepSession.Close.PeerConnection.GracefulClose") + err := whepSession.PeerConnection.Close() + if err != nil { + log.Println("WhepSession.Close.PeerConnection.Error", err) + } + log.Println("WhepSession.Close.PeerConnection.GracefulClose.Completed") + + // Empty tracks + whepSession.AudioLock.Lock() + whepSession.VideoLock.Lock() + + whepSession.AudioTrack = nil + whepSession.VideoTrack = nil + + whepSession.VideoLock.Unlock() + whepSession.AudioLock.Unlock() + + whepSession.ActiveContextCancel() + }) +} + +// Get the current status of the WHEP session +func (whepSession *WhepSession) GetWhepSessionStatus() (state WhepSessionStateDto) { + whepSession.AudioLock.RLock() + whepSession.VideoLock.RLock() + + currentAudioLayer := whepSession.AudioLayerCurrent.Load().(string) + currentVideoLayer := whepSession.VideoLayerCurrent.Load().(string) + + state = WhepSessionStateDto{ + Id: whepSession.SessionId, + + AudioLayerCurrent: currentAudioLayer, + AudioTimestamp: whepSession.AudioTimestamp, + AudioPacketsWritten: whepSession.AudioPacketsWritten, + AudioSequenceNumber: uint64(whepSession.AudioSequenceNumber), + + VideoLayerCurrent: currentVideoLayer, + VideoTimestamp: whepSession.VideoTimestamp, + VideoBitrate: whepSession.VideoBitrate.Load(), + VideoPacketsWritten: whepSession.VideoPacketsWritten, + VideoPacketsDropped: whepSession.VideoPacketsDropped.Load(), + VideoSequenceNumber: uint64(whepSession.VideoSequenceNumber), + } + + whepSession.VideoLock.RUnlock() + whepSession.AudioLock.RUnlock() + + return +} + +// Finds the corresponding Whip session to the Whep session id and sets the requested audio layer +func (whepSession *WhepSession) SetAudioLayer(encodingId string) { + log.Println("Setting Audio Layer") + whepSession.AudioLayerCurrent.Store(encodingId) + whepSession.IsWaitingForKeyframe.Store(true) +} + +// Finds the corresponding Whip session to the Whep session id and sets the requested video layer +func (whepSession *WhepSession) SetVideoLayer(encodingId string) { + log.Println("Setting Video Layer") + whepSession.VideoLayerCurrent.Store(encodingId) + whepSession.IsWaitingForKeyframe.Store(true) +} diff --git a/internal/webrtc/sessions/whep/writer.go b/internal/webrtc/sessions/whep/writer.go new file mode 100644 index 00000000..24719777 --- /dev/null +++ b/internal/webrtc/sessions/whep/writer.go @@ -0,0 +1,66 @@ +package whep + +import ( + "errors" + "io" + "log" + + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" +) + +// Sends provided audio packet to the Whep session +func (whepSession *WhepSession) SendAudioPacket(packet codecs.TrackPacket) { + if whepSession.IsSessionClosed.Load() || whepSession.AudioTrack == nil { + return + } + + whepSession.AudioLock.Lock() + whepSession.AudioPacketsWritten += 1 + whepSession.AudioTimestamp = uint32(int64(whepSession.AudioTimestamp) + packet.TimeDiff) + whepSession.AudioLock.Unlock() + + if err := whepSession.AudioTrack.WriteRTP(packet.Packet, packet.Codec); err != nil { + if errors.Is(err, io.ErrClosedPipe) { + log.Println("WhepSession.SendAudioPacket.ConnectionDropped") + whepSession.Close() + } else { + log.Println("WhepSession.SendAudioPacket.Error", err) + } + } +} + +// Sends provided video packet to the Whep session +func (whepSession *WhepSession) SendVideoPacket(packet codecs.TrackPacket) { + if whepSession.IsSessionClosed.Load() { + return + } + + if whepSession.IsWaitingForKeyframe.Load() { + if !packet.IsKeyframe { + return + } + + whepSession.IsWaitingForKeyframe.Store(false) + } + + whepSession.VideoLock.Lock() + whepSession.VideoBytesWritten += len(packet.Packet.Payload) + whepSession.VideoPacketsWritten += 1 + whepSession.VideoSequenceNumber = uint16(whepSession.VideoSequenceNumber) + uint16(packet.SequenceDiff) + whepSession.VideoTimestamp = uint32(int64(whepSession.VideoTimestamp) + packet.TimeDiff) + whepSession.VideoLock.Unlock() + + packet.Packet.SequenceNumber = whepSession.VideoSequenceNumber + packet.Packet.Timestamp = whepSession.VideoTimestamp + + if err := whepSession.VideoTrack.WriteRTP(packet.Packet, packet.Codec); err != nil { + whepSession.VideoPacketsDropped.Add(1) + + if errors.Is(err, io.ErrClosedPipe) { + log.Println("WhepSession.SendVideoPacket.ConnectionDropped") + whepSession.Close() + } else { + log.Println("WhepSession.SendVideoPacket.Error", err) + } + } +} diff --git a/internal/webrtc/sessions/whip/dtos.go b/internal/webrtc/sessions/whip/dtos.go new file mode 100644 index 00000000..1f04a180 --- /dev/null +++ b/internal/webrtc/sessions/whip/dtos.go @@ -0,0 +1,7 @@ +package whip + +type ( + simulcastLayerResponse struct { + EncodingId string `json:"encodingId"` + } +) diff --git a/internal/webrtc/sessions/whip/handlers.go b/internal/webrtc/sessions/whip/handlers.go new file mode 100644 index 00000000..04ee1ea8 --- /dev/null +++ b/internal/webrtc/sessions/whip/handlers.go @@ -0,0 +1,67 @@ +package whip + +import ( + "log" + "strings" + + "github.com/pion/webrtc/v4" +) + +func (whip *WhipSession) RegisterWhipHandlers(peerConnection *webrtc.PeerConnection, streamKey string) { + log.Println("WhipSession.RegisterHandlers") + + // PeerConnection OnTrack handler + whip.PeerConnection.OnTrack(whip.onTrackHandler(peerConnection, streamKey)) + + // PeerConnection OnICEConnectionStateChange handler + whip.PeerConnection.OnICEConnectionStateChange(whip.onICEConnectionStateChangeHandler()) + + // PeerConnection OnConnectionStateChange + whip.PeerConnection.OnConnectionStateChange(whip.onConnectionStateChange()) +} + +func (whip *WhipSession) onICEConnectionStateChangeHandler() func(webrtc.ICEConnectionState) { + return func(state webrtc.ICEConnectionState) { + if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { + log.Println("WhipSession.PeerConnection.OnICEConnectionStateChange", whip.Id) + whip.ActiveContextCancel() + } + } +} + +func (whip *WhipSession) onTrackHandler(peerConnection *webrtc.PeerConnection, streamKey string) func(*webrtc.TrackRemote, *webrtc.RTPReceiver) { + return func(remoteTrack *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) { + log.Println("WhipSession.PeerConnection.OnTrackHandler", whip.Id) + whip.OnTrackChangeChannel <- struct{}{} + + if strings.HasPrefix(remoteTrack.Codec().MimeType, "audio") { + // Handle audio stream + whip.AudioWriter(remoteTrack, streamKey, peerConnection) + } else { + // Handle video stream + whip.VideoWriter(remoteTrack, streamKey, peerConnection) + } + + // Fires when track has stopped + whip.OnTrackChangeChannel <- struct{}{} + + log.Println("WhipSession.OnTrackHandler.TrackStopped", remoteTrack.RID()) + } +} + +func (whip *WhipSession) onConnectionStateChange() func(webrtc.PeerConnectionState) { + return func(state webrtc.PeerConnectionState) { + log.Println("WhipSession.PeerConnection.OnConnectionStateChange", state) + + switch state { + case webrtc.PeerConnectionStateClosed: + case webrtc.PeerConnectionStateFailed: + log.Println("WhipSession.PeerConnection.OnConnectionStateChange: Host removed", whip.Id) + whip.ActiveContextCancel() + + case webrtc.PeerConnectionStateConnected: + log.Println("WhipSession.PeerConnection.OnConnectionStateChange: Host connected", whip.Id) + + } + } +} diff --git a/internal/webrtc/sessions/whip/peerconnection.go b/internal/webrtc/sessions/whip/peerconnection.go new file mode 100644 index 00000000..42d56f2e --- /dev/null +++ b/internal/webrtc/sessions/whip/peerconnection.go @@ -0,0 +1,44 @@ +package whip + +import ( + "log" + + "github.com/pion/webrtc/v4" +) + +func (whip *WhipSession) AddPeerConnection(peerConnection *webrtc.PeerConnection, streamKey string) { + log.Println("WhipSession.AddPeerConnection") + + whip.PeerConnectionLock.Lock() + existingPeerConnection := whip.PeerConnection + whip.PeerConnection = peerConnection + whip.PeerConnectionLock.Unlock() + + if existingPeerConnection != nil && existingPeerConnection != peerConnection { + log.Println("WhipSession.AddPeerConnection: Replacing existing peerconnection") + if err := existingPeerConnection.GracefulClose(); err != nil { + log.Println("WhipSession.AddPeerConnection.Close.Error", err) + } + } + + whip.RegisterWhipHandlers(peerConnection, streamKey) +} + +func (whip *WhipSession) RemovePeerConnection() { + log.Println("WhipSession.RemovePeerConnection", whip.Id) + + whip.PeerConnectionLock.Lock() + peerConnection := whip.PeerConnection + whip.PeerConnection = nil + whip.PeerConnectionLock.Unlock() + + if peerConnection == nil { + return + } + + if err := peerConnection.Close(); err != nil { + log.Println("WhipSession.RemovePeerConnection.Error", err) + } + + log.Println("WhipSession.RemovePeerConnection.Completed", whip.Id) +} diff --git a/internal/webrtc/sessions/whip/sse.go b/internal/webrtc/sessions/whip/sse.go new file mode 100644 index 00000000..75414f60 --- /dev/null +++ b/internal/webrtc/sessions/whip/sse.go @@ -0,0 +1,46 @@ +package whip + +import ( + "encoding/json" + "log" +) + +// Returns all available Video and Audio layers of the provided stream key +func (whip *WhipSession) GetAvailableLayersEvent() string { + videoLayers := []simulcastLayerResponse{} + audioLayers := []simulcastLayerResponse{} + + whip.TracksLock.RLock() + + // Add available video layers + for track := range whip.VideoTracks { + videoLayers = append(videoLayers, simulcastLayerResponse{ + EncodingId: whip.VideoTracks[track].Rid, + }) + } + + // Add available audio layers + for track := range whip.AudioTracks { + audioLayers = append(audioLayers, simulcastLayerResponse{ + EncodingId: whip.AudioTracks[track].Rid, + }) + } + + whip.TracksLock.RUnlock() + + resp := map[string]map[string][]simulcastLayerResponse{ + "1": { + "layers": videoLayers, + }, + "2": { + "layers": audioLayers, + }, + } + + jsonResult, err := json.Marshal(resp) + if err != nil { + log.Println("Error converting response", resp, "to Json") + } + + return "event: layers\ndata: " + string(jsonResult) + "\n\n" +} diff --git a/internal/webrtc/sessions/whip/track.go b/internal/webrtc/sessions/whip/track.go new file mode 100644 index 00000000..1d86f501 --- /dev/null +++ b/internal/webrtc/sessions/whip/track.go @@ -0,0 +1,139 @@ +package whip + +import ( + "log" + "time" + + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/google/uuid" + "github.com/pion/webrtc/v4" +) + +// Add a new AudioTrack to the Whip session +func (whip *WhipSession) AddAudioTrack(rid string, streamKey string, codec codecs.TrackCodeType) (*AudioTrack, error) { + log.Println("WhipSession.AddAudioTrack:", streamKey, "(", rid, ")") + whip.TracksLock.Lock() + defer whip.TracksLock.Unlock() + + if existingTrack, ok := whip.AudioTracks[rid]; ok { + return existingTrack, nil + } + + track := &AudioTrack{ + Rid: rid, + SessionId: whip.Id, + Track: codecs.CreateTrackMultiCodec( + "audio-"+uuid.New().String(), + rid, + streamKey, + webrtc.RTPCodecTypeAudio, + codec), + } + track.LastReceived.Store(time.Time{}) + + whip.AudioTracks[track.Rid] = track + + return track, nil +} + +// Add a new VideoTrack to the Whip session +func (whip *WhipSession) AddVideoTrack(rid string, streamKey string, codec codecs.TrackCodeType) (*VideoTrack, error) { + log.Println("WhipSession.AddVideoTrack:", "(", rid, ")") + whip.TracksLock.Lock() + defer whip.TracksLock.Unlock() + + if existingTrack, ok := whip.VideoTracks[rid]; ok { + return existingTrack, nil + } + + track := &VideoTrack{ + Rid: rid, + SessionId: whip.Id, + Track: codecs.CreateTrackMultiCodec( + "video-"+uuid.New().String(), + rid, + streamKey, + webrtc.RTPCodecTypeVideo, + codec), + } + track.LastReceived.Store(time.Time{}) + + whip.VideoTracks[rid] = track + + return track, nil +} + +// Remove Audio and Video tracks coming from the whip session id +func (whip *WhipSession) RemoveTracks() { + log.Println("WhipSession.RemoveTracks") + + whip.TracksLock.Lock() + whip.AudioTracks = make(map[string]*AudioTrack) + whip.VideoTracks = make(map[string]*VideoTrack) + whip.TracksLock.Unlock() + + whip.OnTrackChangeChannel <- struct{}{} +} + +// Get highest prioritized audio track in the whip session +// This only works if the priority has been set. +// Currently this is only supported when being set through the simulcast +// property in the offer made by the whip connection +func (whip *WhipSession) GetHighestPrioritizedAudioTrack() string { + if len(whip.AudioTracks) == 0 { + log.Println("No Audio tracks was found for", whip.Id) + return "" + } + + whip.TracksLock.RLock() + var highestPriorityAudioTrack *AudioTrack + for _, trackPriority := range whip.AudioTracks { + if highestPriorityAudioTrack == nil { + highestPriorityAudioTrack = trackPriority + continue + } + + if trackPriority.Priority < highestPriorityAudioTrack.Priority { + highestPriorityAudioTrack = trackPriority + } + } + whip.TracksLock.RUnlock() + + if highestPriorityAudioTrack == nil { + return "" + } + + return highestPriorityAudioTrack.Rid + +} + +// Get highest prioritized video track in the whip session +// This only works if the priority has been set. +// Currently this is only supported when being set through the simulcast +// property in the offer made by the whip connection +func (whip *WhipSession) GetHighestPrioritizedVideoTrack() string { + if len(whip.VideoTracks) == 0 { + log.Println("No Video tracks was found for", whip.Id) + } + + var highestPriorityVideoTrack *VideoTrack + + whip.TracksLock.RLock() + for _, trackPriority := range whip.VideoTracks { + if highestPriorityVideoTrack == nil { + highestPriorityVideoTrack = trackPriority + continue + } + + if trackPriority.Priority < highestPriorityVideoTrack.Priority { + highestPriorityVideoTrack = trackPriority + } + } + whip.TracksLock.RUnlock() + + if highestPriorityVideoTrack == nil { + return "" + } + + return highestPriorityVideoTrack.Rid +} diff --git a/internal/webrtc/sessions/whip/type_whip.go b/internal/webrtc/sessions/whip/type_whip.go new file mode 100644 index 00000000..3a1f0caf --- /dev/null +++ b/internal/webrtc/sessions/whip/type_whip.go @@ -0,0 +1,57 @@ +package whip + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/pion/webrtc/v4" +) + +type ( + WhipSession struct { + Id string + ContextLock sync.RWMutex + ActiveContext context.Context + ActiveContextCancel func() + PeerConnectionLock sync.RWMutex + PeerConnection *webrtc.PeerConnection + + OnTrackChangeChannel chan struct{} + EventsChannel chan any + + PacketLossIndicationChannel chan bool + + // Protects AudioTrack, VideoTracks + TracksLock sync.RWMutex + VideoTracks map[string]*VideoTrack + AudioTracks map[string]*AudioTrack + + //TODO: WhepSessionsSnapshot should only contain information about the current state of the session, not + // references to chans and other types that cannot be json serialized. + // Create interface for the purpose and use that with the atomic specifically + WhepSessionsSnapshot atomic.Value + } + + VideoTrack struct { + Rid string + SessionId string + Priority int + Bitrate atomic.Uint64 + PacketsReceived atomic.Uint64 + PacketsDropped atomic.Uint64 + LastReceived atomic.Value + LastKeyFrame atomic.Value + Track *codecs.TrackMultiCodec + } + AudioTrack struct { + Rid string + SessionId string + Priority int + PacketsReceived atomic.Uint64 + PacketsDropped atomic.Uint64 + LastReceived atomic.Value + Track *codecs.TrackMultiCodec + } +) diff --git a/internal/webrtc/sessions/whip/writers.go b/internal/webrtc/sessions/whip/writers.go new file mode 100644 index 00000000..e91ab6a1 --- /dev/null +++ b/internal/webrtc/sessions/whip/writers.go @@ -0,0 +1,436 @@ +package whip + +import ( + "errors" + "io" + "log" + "math" + "os" + "strings" + "time" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/whep" + "github.com/pion/rtcp" + "github.com/pion/rtp" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" + + pionCodecs "github.com/pion/rtp/codecs" +) + +func (whip *WhipSession) AudioWriter(remoteTrack *webrtc.TrackRemote, streamKey string, peerConnection *webrtc.PeerConnection) { + experimentalWhepPacketDeepClone := strings.EqualFold(os.Getenv(environment.WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS), "true") + id := remoteTrack.RID() + + if id == "" { + id = codecs.AudioTrackLabelDefault + } + + codec := codecs.GetAudioTrackCodec(remoteTrack.Codec().MimeType) + track, err := whip.AddAudioTrack(id, streamKey, codec) + if err != nil { + log.Println("AudioWriter.AddTrack.Error:", err) + return + } + + track.Priority = whip.getPrioritizedStreamingLayer(id, peerConnection.CurrentRemoteDescription().SDP) + + var sessions map[string]*whep.WhepSession + go func() { + ticker := time.NewTicker(1 * time.Second) + + for { + select { + case <-whip.ActiveContext.Done(): + return + case <-ticker.C: + sessionsAny := whip.WhepSessionsSnapshot.Load() + + if sessionsAny == nil { + continue + } + + sessions = sessionsAny.(map[string]*whep.WhepSession) + } + } + }() + + rtpPkt := &rtp.Packet{} + rtpBuf := make([]byte, 1500) + for { + rtpRead, _, err := remoteTrack.Read(rtpBuf) + if err != nil { + if errors.Is(err, io.EOF) { + log.Println("WhipSession.AudioWriter.RtpPkt.EndOfStream") + return + } else { + log.Println("WhipSession.AudioWriter.RtpPkt.Err", err) + } + } + + track.PacketsReceived.Add(1) + + err = rtpPkt.Unmarshal(rtpBuf[:rtpRead]) + if err != nil { + log.Println("WhipSession.AudioWriter.RtpPkt.Error", err) + continue + } + + if experimentalWhepPacketDeepClone { + pktClone := *rtpPkt + pktClone.Payload = append([]byte(nil), rtpPkt.Payload...) + pktClone.Extensions = append([]rtp.Extension(nil), rtpPkt.Extensions...) + + for _, whepSession := range sessions { + whepSession.SendAudioPacket(codecs.TrackPacket{ + Layer: id, + Packet: &pktClone, + Codec: codec, + }) + } + + } else { + packet := codecs.TrackPacket{ + Layer: id, + Packet: rtpPkt, + Codec: codec, + } + + for _, whepSession := range sessions { + if whepSession.AudioLayerCurrent.Load() == id { + whepSession.SendAudioPacket(packet) + } + } + } + + } +} + +func (whip *WhipSession) VideoWriter(remoteTrack *webrtc.TrackRemote, streamKey string, peerConnection *webrtc.PeerConnection) { + experimentalWhepPacketDeepClone := strings.EqualFold(os.Getenv(environment.WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS), "true") + experimentalWhepPacketDeepCloneToChannel := strings.EqualFold(os.Getenv(environment.WHEP_EXPERIMENTAL_DEEPCOPY_PACKETS_TO_CHANNEL), "true") + + id := remoteTrack.RID() + + if id == "" { + id = codecs.VideoTrackLabelDefault + } + + codec := codecs.GetVideoTrackCodec(remoteTrack.Codec().MimeType) + track, err := whip.AddVideoTrack(id, streamKey, codec) + if err != nil { + log.Println("WhipSession.VideoWriter.AddTrack.Error:", err) + return + } + track.Priority = whip.getPrioritizedStreamingLayer(id, peerConnection.CurrentRemoteDescription().SDP) + + go whipStreamVideoWriterChannels(remoteTrack, whip, peerConnection) + + var depacketizer rtp.Depacketizer + switch codec { + case codecs.VideoTrackCodecH264: + depacketizer = &pionCodecs.H264Packet{} + case codecs.VideoTrackCodecH265: + depacketizer = &pionCodecs.H265Depacketizer{} + case codecs.VideoTrackCodecVP8: + depacketizer = &pionCodecs.VP8Packet{} + case codecs.VideoTrackCodecVP9: + depacketizer = &pionCodecs.VP9Packet{} + } + + if depacketizer == nil { + log.Println("WhipSession.VideoWriter.Depacketizer: No depacketizer was found for codec", codec) + } + + lastTimestamp := uint32(0) + lastTimestampSet := false + + lastSequenceNumber := uint16(0) + lastSequenceNumberSet := false + + bytesReceived := int(0) + + // Calculate bitrate + go func() { + ticker := time.NewTicker(1 * time.Second) + + trackedBytesReceived := int(0) + + for { + select { + case <-whip.ActiveContext.Done(): + return + case <-ticker.C: + track.Bitrate.Store(uint64(bytesReceived) - uint64(trackedBytesReceived)) + trackedBytesReceived = bytesReceived + } + } + }() + + // Update sessions snapshot + var sessions map[string]*whep.WhepSession + go func() { + ticker := time.NewTicker(1 * time.Second) + + for { + select { + case <-whip.ActiveContext.Done(): + return + case <-ticker.C: + sessionsAny := whip.WhepSessionsSnapshot.Load() + + if sessionsAny == nil { + continue + } + + sessions = sessionsAny.(map[string]*whep.WhepSession) + } + } + }() + + rtpPkt := &rtp.Packet{} + pktBuf := make([]byte, 1500) + for { + + select { + case <-whip.ActiveContext.Done(): + return + default: + } + + rtpRead, _, err := remoteTrack.Read(pktBuf) + if err != nil { + if errors.Is(err, io.EOF) { + log.Println("WhipSession.VideoWriter.RtpPkt.EndOfStream") + whip.ActiveContextCancel() + return + } else { + log.Println("WhipSession.VideoWriter.RtpPkt.Err", err) + } + } + + if rtpRead == 0 { + continue + } + + err = rtpPkt.Unmarshal(pktBuf[:rtpRead]) + if err != nil { + log.Println("WhipSession.VideoWriter.RtpPkt.Unmarshal", err) + continue + } + + track.PacketsReceived.Add(1) + bytesReceived += rtpRead + + isKeyframe := false + if codec == codecs.VideoTrackCodecH264 { + isKeyframe = isPacketKeyframe(rtpPkt, codec, depacketizer) + if isKeyframe { + track.LastKeyFrame.Store(time.Now()) + } + } + + timeDiff := int64(rtpPkt.Timestamp) - int64(lastTimestamp) + switch { + case !lastTimestampSet: + timeDiff = 0 + lastTimestampSet = true + case timeDiff < -(math.MaxUint32 / 10): + timeDiff += (math.MaxUint32 + 1) + } + + sequenceDiff := int(rtpPkt.SequenceNumber) - int(lastSequenceNumber) + switch { + case !lastSequenceNumberSet: + lastSequenceNumberSet = true + sequenceDiff = 0 + case sequenceDiff < -(math.MaxUint16 / 10): + sequenceDiff += (math.MaxUint16 + 1) + } + + lastTimestamp = rtpPkt.Timestamp + lastSequenceNumber = rtpPkt.SequenceNumber + + switch { + case experimentalWhepPacketDeepClone: + sendVideoClonedPacketToWhep( + id, + sessions, + rtpPkt, + codec, + isKeyframe, + timeDiff, + sequenceDiff) + + case experimentalWhepPacketDeepCloneToChannel: + sendVideoClonedPacketToWhepChannel( + id, + sessions, + rtpPkt, + codec, + isKeyframe, + timeDiff, + sequenceDiff) + + default: + sendVideoSharedPacketToWhep(id, + sessions, + codecs.TrackPacket{ + Layer: id, + Packet: rtpPkt, + Codec: codec, + IsKeyframe: isKeyframe, + TimeDiff: timeDiff, + SequenceDiff: sequenceDiff, + }) + } + } +} + +func sendVideoClonedPacketToWhepChannel(id string, + sessions map[string]*whep.WhepSession, + rtpPkt *rtp.Packet, + codec codecs.TrackCodeType, + isKeyframe bool, + timeDiff int64, + sequenceDiff int) { + for _, whepSession := range sessions { + if whepSession.IsSessionClosed.Load() || whepSession.VideoLayerCurrent.Load() != id { + continue + } + + pkt := codecs.TrackPacket{ + Layer: id, + Codec: codec, + IsKeyframe: isKeyframe, + TimeDiff: timeDiff, + SequenceDiff: sequenceDiff, + } + // Packet deep copy pr. listener + pktClone := *rtpPkt + pktClone.Payload = append([]byte(nil), rtpPkt.Payload...) + pktClone.Extensions = append([]rtp.Extension(nil), rtpPkt.Extensions...) + pkt.Packet = &pktClone + + select { + case <-whepSession.ActiveContext.Done(): + continue + case whepSession.VideoChannel <- pkt: + default: // Drop + whepSession.VideoPacketsDropped.Add(1) + whepSession.IsWaitingForKeyframe.Store(true) + } + } +} + +func sendVideoSharedPacketToWhep(id string, sessions map[string]*whep.WhepSession, packet codecs.TrackPacket) { + for _, whepSession := range sessions { + if whepSession.VideoLayerCurrent.Load() == id { + whepSession.SendVideoPacket(packet) + } + } +} + +func sendVideoClonedPacketToWhep(id string, sessions map[string]*whep.WhepSession, rtpPkt *rtp.Packet, codec codecs.TrackCodeType, isKeyframe bool, timeDiff int64, sequenceDiff int) { + for _, whepSession := range sessions { + if whepSession.VideoLayerCurrent.Load() == id { + // Packet deep copy pr. listener + pktClone := *rtpPkt + pktClone.Payload = append([]byte(nil), rtpPkt.Payload...) + pktClone.Extensions = append([]rtp.Extension(nil), rtpPkt.Extensions...) + + whepSession.SendVideoPacket(codecs.TrackPacket{ + Layer: id, + Packet: &pktClone, + Codec: codec, + IsKeyframe: isKeyframe, + TimeDiff: timeDiff, + SequenceDiff: sequenceDiff, + }) + } + } +} + +const ( + naluTypeBitmask = 0x1f + + idrNALUType = 5 + spsNALUType = 7 + ppsNALUType = 8 +) + +func isPacketKeyframe(pkt *rtp.Packet, codec codecs.TrackCodeType, depacketizer rtp.Depacketizer) bool { + if codec == codecs.VideoTrackCodecH264 { + nalu, err := depacketizer.Unmarshal(pkt.Payload) + + if err != nil || len(nalu) < 6 { + return false + } + + firstNaluType := nalu[4] & naluTypeBitmask + return firstNaluType == idrNALUType || firstNaluType == spsNALUType || firstNaluType == ppsNALUType + } + + return true +} + +// Triggers a request for a new key frame if it has been requested +func whipStreamVideoWriterChannels(remoteTrack *webrtc.TrackRemote, whipSession *WhipSession, peerConnection *webrtc.PeerConnection) { + var lastCall = time.Now() + + for { + { + select { + case <-whipSession.ActiveContext.Done(): + return + case <-whipSession.PacketLossIndicationChannel: + if lastCall.Add(time.Second * 2).Before(time.Now()) { + log.Println("WhipSession.WhipStreamVideoWriterChannels.Trigger.PLI") + lastCall = time.Now() + + if sendError := peerConnection.WriteRTCP([]rtcp.Packet{ + &rtcp.PictureLossIndication{ + MediaSSRC: uint32(remoteTrack.SSRC()), + }, + }); sendError != nil { + return + } + } + } + } + } +} + +// Helper function for getting the simulcast order and using as priority for consumers +// This example will order from left to right with highest to lowest priority +// a=simulcast:send High,Mid,Low +func (whip *WhipSession) getPrioritizedStreamingLayer(layer string, sdpDescription string) int { + var sessionDescription sdp.SessionDescription + err := sessionDescription.Unmarshal([]byte(sdpDescription)) + if err != nil { + log.Println("Track.getPrioritizedStreamingLayer Error: (Layer "+layer+")", err) + return 100 + } + + var priority = 1 + for _, description := range sessionDescription.MediaDescriptions { + for _, attribute := range description.Attributes { + if attribute.Key == "simulcast" && strings.HasPrefix(attribute.Value, "send ") { + layers := strings.TrimPrefix(attribute.Value, "send") + log.Println("WhipSession.VideoWriter.TrackPriority:", layers) + for simulcastLayer := range strings.SplitSeq(strings.TrimSpace(layers), ";") { + if simulcastLayer != "" && strings.EqualFold(simulcastLayer, layer) { + log.Println("WhipSession.VideoWriter.TrackPriority:", layer) + return priority + } else { + priority++ + } + } + } + } + } + + return 100 +} diff --git a/internal/webrtc/settings.go b/internal/webrtc/settings.go new file mode 100644 index 00000000..3c139194 --- /dev/null +++ b/internal/webrtc/settings.go @@ -0,0 +1,216 @@ +package webrtc + +import ( + "net" + "strings" + + "github.com/pion/dtls/v3/pkg/crypto/elliptic" + "github.com/pion/ice/v4" + "github.com/pion/webrtc/v4" + + "log" + "os" + "strconv" + + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/ip" +) + +func GetSettingEngine(isWhip bool, tcpMuxCache map[string]ice.TCPMux, udpMuxCache map[int]*ice.MultiUDPMuxDefault) (settingEngine webrtc.SettingEngine) { + var ( + udpMuxOpts []ice.UDPMuxFromPortOption + ) + + setupNetworkTypes() + setupNAT(&settingEngine) + setupInterfaceFilter(&settingEngine, &udpMuxOpts) + setupUDPMux(&settingEngine, isWhip, udpMuxCache, udpMuxOpts) + setupTCPMux(&settingEngine, tcpMuxCache) + + settingEngine.SetDTLSEllipticCurves(elliptic.X25519, elliptic.P384, elliptic.P256) + settingEngine.SetNetworkTypes(setupNetworkTypes()) + settingEngine.DisableSRTCPReplayProtection(true) + settingEngine.DisableSRTPReplayProtection(true) + settingEngine.SetIncludeLoopbackCandidate(os.Getenv(environment.INCLUDE_LOOPBACK_CANDIDATE) != "") + + return +} + +func setupNetworkTypes() []webrtc.NetworkType { + networkTypesEnv := os.Getenv(environment.NETWORK_TYPES) + tcpMuxForce := os.Getenv(environment.TCP_MUX_FORCE) + + networkTypes := []webrtc.NetworkType{} + // TCP Mux Force will enforce TCP4/6 instead of requested types + if tcpMuxForce != "" { + networkTypes = []webrtc.NetworkType{ + webrtc.NetworkTypeTCP4, + webrtc.NetworkTypeTCP6, + } + } + + if networkTypesEnv != "" { + for networkTypeStr := range strings.SplitSeq(networkTypesEnv, "|") { + networkType, err := webrtc.NewNetworkType(networkTypeStr) + if err != nil { + networkTypes = append(networkTypes, networkType) + } + } + } else { + // No network types found, use default values + networkTypes = append(networkTypes, []webrtc.NetworkType{webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6}...) + } + + return networkTypes +} + +func setupTCPMux(settingEngine *webrtc.SettingEngine, tcpMuxCache map[string]ice.TCPMux) { + // Use TCP Mux port if set + if tcpAddr := getTCPMuxAddress(); tcpAddr != nil { + address := os.Getenv(environment.TCP_MUX_ADDRESS) + tcpMux, ok := tcpMuxCache[address] + + if !ok { + tcpListener, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + log.Fatal(err) + } + + tcpMux = webrtc.NewICETCPMux(nil, tcpListener, 8) + tcpMuxCache[address] = tcpMux + } + + settingEngine.SetICETCPMux(tcpMux) + } +} + +func setupUDPMux(settingEngine *webrtc.SettingEngine, isWhip bool, udpMuxCache map[int]*ice.MultiUDPMuxDefault, udpMuxOpts []ice.UDPMuxFromPortOption) { + // Use UDP Mux port if set + if udpMuxPort := getUDPMuxPort(isWhip); udpMuxPort != 0 { + setUDPMuxPort(isWhip, udpMuxPort, udpMuxCache, udpMuxOpts, settingEngine) + } +} + +func setupInterfaceFilter(settingEngine *webrtc.SettingEngine, muxOpts *[]ice.UDPMuxFromPortOption) { + filter := os.Getenv(environment.INTERFACE_FILTER) + + if filter != "" { + interfaceFilter := func(i string) bool { + return i == filter + } + + settingEngine.SetInterfaceFilter(interfaceFilter) + *muxOpts = append(*muxOpts, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter)) + } +} + +func getTCPMuxAddress() *net.TCPAddr { + sharedAddress := os.Getenv(environment.TCP_MUX_ADDRESS) + + if sharedAddress != "" { + tcpAddr, err := net.ResolveTCPAddr("tcp", sharedAddress) + + if err != nil { + log.Fatal(err) + } + + return tcpAddr + } + + return nil +} + +func getUDPMuxPort(isWhip bool) int { + sharedPort := os.Getenv(environment.UDP_MUX_PORT) + whipPort := os.Getenv(environment.UDP_MUX_PORT_WHIP) + whepPort := os.Getenv(environment.UDP_MUX_PORT_WHEP) + + // Set for WHIP + if isWhip && whipPort != "" { + port, err := strconv.Atoi(whipPort) + if err != nil { + log.Fatal(err) + } + + return port + } + + // Set for WHEP + if !isWhip && whepPort != "" { + port, err := strconv.Atoi(whepPort) + if err != nil { + log.Fatal(err) + } + + return port + } + + // Set generalized + if sharedPort != "" { + port, err := strconv.Atoi(sharedPort) + if err != nil { + log.Fatal(err) + } + + return port + } + + // Do not use mux + return 0 +} + +func setUDPMuxPort(isWhip bool, udpMuxPort int, udpMuxCache map[int]*ice.MultiUDPMuxDefault, udpMuxOpts []ice.UDPMuxFromPortOption, settingEngine *webrtc.SettingEngine) { + if isWhip { + log.Println("Setting up WHIP UDP Mux to", udpMuxPort) + } else { + log.Println("Setting up WHEP UDP Mux to", udpMuxPort) + } + + udpMux, ok := udpMuxCache[udpMuxPort] + + if !ok { + // No Mux for current port, create new + newUdpMux, err := ice.NewMultiUDPMuxFromPort(udpMuxPort, udpMuxOpts...) + + if err != nil { + log.Fatal(err) + } + + udpMuxCache[udpMuxPort] = newUdpMux + udpMux = newUdpMux + } + + // Set to Mux on existing port + settingEngine.SetICEUDPMux(udpMux) +} + +func setupNAT(settingEngine *webrtc.SettingEngine) { + var ( + natIps []string + ) + + natICECandidateType := webrtc.ICECandidateTypeHost + + if os.Getenv(environment.INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP) != "" { + natIps = append(natIps, ip.GetPublicIp()) + } + + if os.Getenv(environment.NAT_1_TO_1_IP) != "" { + natIps = append(natIps, strings.Split(os.Getenv(environment.NAT_1_TO_1_IP), "|")...) + } + + if os.Getenv(environment.NAT_ICE_CANDIDATE_TYPE) == "srflx" { + natICECandidateType = webrtc.ICECandidateTypeSrflx + } + + if len(natIps) != 0 { + if err := settingEngine.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{ + External: natIps, + AsCandidateType: natICECandidateType, + Mode: webrtc.ICEAddressRewriteAppend, + }); err != nil { + log.Fatal("Configuration error: INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP:", err) + } + + } +} diff --git a/internal/webrtc/track_multi_codec.go b/internal/webrtc/track_multi_codec.go deleted file mode 100644 index 5d900032..00000000 --- a/internal/webrtc/track_multi_codec.go +++ /dev/null @@ -1,69 +0,0 @@ -package webrtc - -import ( - "github.com/pion/rtp" - "github.com/pion/webrtc/v4" -) - -type trackMultiCodec struct { - ssrc webrtc.SSRC - writeStream webrtc.TrackLocalWriter - - payloadTypeH264, payloadTypeH265, payloadTypeVP8, payloadTypeVP9, payloadTypeAV1 uint8 - - id, rid, streamID string -} - -func (t *trackMultiCodec) Bind(ctx webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) { - t.ssrc = ctx.SSRC() - t.writeStream = ctx.WriteStream() - - codecs := ctx.CodecParameters() - for i := range codecs { - switch getVideoTrackCodec(codecs[i].MimeType) { - case videoTrackCodecH264: - t.payloadTypeH264 = uint8(codecs[i].PayloadType) - case videoTrackCodecVP8: - t.payloadTypeVP8 = uint8(codecs[i].PayloadType) - case videoTrackCodecVP9: - t.payloadTypeVP9 = uint8(codecs[i].PayloadType) - case videoTrackCodecAV1: - t.payloadTypeAV1 = uint8(codecs[i].PayloadType) - case videoTrackCodecH265: - t.payloadTypeH265 = uint8(codecs[i].PayloadType) - } - } - - return webrtc.RTPCodecParameters{RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264, RTCPFeedback: videoRTCPFeedback}}, nil -} - -func (t *trackMultiCodec) Unbind(webrtc.TrackLocalContext) error { - return nil -} - -func (t *trackMultiCodec) WriteRTP(p *rtp.Packet, codec videoTrackCodec) error { - p.SSRC = uint32(t.ssrc) - - switch codec { - case videoTrackCodecH264: - p.PayloadType = t.payloadTypeH264 - case videoTrackCodecVP8: - p.PayloadType = t.payloadTypeVP8 - case videoTrackCodecVP9: - p.PayloadType = t.payloadTypeVP9 - case videoTrackCodecAV1: - p.PayloadType = t.payloadTypeAV1 - case videoTrackCodecH265: - p.PayloadType = t.payloadTypeH265 - } - - _, err := t.writeStream.WriteRTP(&p.Header, p.Payload) - return err -} - -func (t *trackMultiCodec) ID() string { return t.id } -func (t *trackMultiCodec) RID() string { return t.rid } -func (t *trackMultiCodec) StreamID() string { return t.streamID } -func (t *trackMultiCodec) Kind() webrtc.RTPCodecType { - return webrtc.RTPCodecTypeVideo -} diff --git a/internal/webrtc/utils/append_answer.go b/internal/webrtc/utils/append_answer.go new file mode 100644 index 00000000..26c28dcd --- /dev/null +++ b/internal/webrtc/utils/append_answer.go @@ -0,0 +1,18 @@ +package utils + +import ( + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +// Appends a candidate to the list of candidates that are sent back to the client in the answer +func AppendCandidateToAnswer(localDescriptionSFP string) string { + if appendCandidate := os.Getenv(environment.APPEND_CANDIDATE); appendCandidate != "" { + index := strings.Index(localDescriptionSFP, "a=end-of-candidates") + localDescriptionSFP = localDescriptionSFP[:index] + appendCandidate + localDescriptionSFP[index:] + } + + return localDescriptionSFP +} diff --git a/internal/webrtc/utils/json.go b/internal/webrtc/utils/json.go new file mode 100644 index 00000000..572fcf3d --- /dev/null +++ b/internal/webrtc/utils/json.go @@ -0,0 +1,16 @@ +package utils + +import ( + "encoding/json" + "log" +) + +func ToJsonString(content any) (jsonString string, err error) { + jsonResult, err := json.Marshal(content) + if err != nil { + log.Println("Error converting response", content, "to Json") + return "", err + } + + return string(jsonResult), nil +} diff --git a/internal/webrtc/utils/logging.go b/internal/webrtc/utils/logging.go new file mode 100644 index 00000000..f5881ad6 --- /dev/null +++ b/internal/webrtc/utils/logging.go @@ -0,0 +1,25 @@ +package utils + +import ( + "log" + "os" + "strings" + + "github.com/glimesh/broadcast-box/internal/environment" +) + +func DebugOutputOffer(offer string) string { + if strings.EqualFold(os.Getenv(environment.DEBUG_PRINT_OFFER), "true") { + log.Println(offer) + } + + return offer +} + +func DebugOutputAnswer(answer string) string { + if strings.EqualFold(os.Getenv(environment.DEBUG_PRINT_ANSWER), "true") { + log.Println(answer) + } + + return answer +} diff --git a/internal/webrtc/utils/offer_validator.go b/internal/webrtc/utils/offer_validator.go new file mode 100644 index 00000000..e47a736b --- /dev/null +++ b/internal/webrtc/utils/offer_validator.go @@ -0,0 +1,8 @@ +package utils + +import "github.com/pion/sdp/v3" + +func ValidateOffer(offer string) error { + var parsed sdp.SessionDescription + return parsed.Unmarshal([]byte(offer)) +} diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 1c938d8d..79aed094 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -1,569 +1,100 @@ package webrtc import ( - "context" - "encoding/json" "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "os" - "slices" - "strconv" "strings" - "sync" - "sync/atomic" - "time" - "github.com/pion/dtls/v3/pkg/crypto/elliptic" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/glimesh/broadcast-box/internal/webrtc/interceptors" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" "github.com/pion/ice/v4" "github.com/pion/interceptor" "github.com/pion/webrtc/v4" ) -const ( - videoTrackLabelDefault = "default" - - videoTrackCodecH264 videoTrackCodec = iota + 1 - videoTrackCodecVP8 - videoTrackCodecVP9 - videoTrackCodecAV1 - videoTrackCodecH265 -) - -type ( - stream struct { - // Does this stream have a publisher? - // If stream was created by a WHEP request hasWHIPClient == false - hasWHIPClient atomic.Bool - sessionId string - - firstSeenEpoch uint64 - - videoTracks []*videoTrack - - audioTrack *webrtc.TrackLocalStaticRTP - audioPacketsReceived atomic.Uint64 - - pliChan chan any - - whipActiveContext context.Context - whipActiveContextCancel func() - - peerConnection atomic.Pointer[webrtc.PeerConnection] - - whepSessionsLock sync.RWMutex - whepSessions map[string]*whepSession - } - - videoTrack struct { - sessionId string - rid string - packetsReceived atomic.Uint64 - lastKeyFrameSeen atomic.Value - } - - videoTrackCodec int -) - -var ( - streamMap map[string]*stream - streamMapLock sync.Mutex - apiWhip, apiWhep *webrtc.API - - errNoPeerConnection = errors.New("unable to find PeerConnection") - errICERestartNotSupported = errors.New("ice restart not supported") - - // nolint - videoRTCPFeedback = []webrtc.RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}, {"nack", "pli"}} -) - -func getVideoTrackCodec(in string) videoTrackCodec { - downcased := strings.ToLower(in) - switch { - case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeH264)): - return videoTrackCodecH264 - case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeVP8)): - return videoTrackCodecVP8 - case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeVP9)): - return videoTrackCodecVP9 - case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeAV1)): - return videoTrackCodecAV1 - case strings.Contains(downcased, strings.ToLower(webrtc.MimeTypeH265)): - return videoTrackCodecH265 - } - - return 0 -} - -func getStream(streamKey string, whipSessionId string) (*stream, error) { - foundStream, ok := streamMap[streamKey] - if !ok { - audioTrack, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") - if err != nil { - return nil, err - } - - whipActiveContext, whipActiveContextCancel := context.WithCancel(context.Background()) - - foundStream = &stream{ - audioTrack: audioTrack, - pliChan: make(chan any, 50), - whepSessions: map[string]*whepSession{}, - whipActiveContext: whipActiveContext, - whipActiveContextCancel: whipActiveContextCancel, - firstSeenEpoch: uint64(time.Now().Unix()), - } - streamMap[streamKey] = foundStream - } - - if whipSessionId != "" { - foundStream.hasWHIPClient.Store(true) - foundStream.sessionId = whipSessionId - } - - return foundStream, nil -} - -func peerConnectionDisconnected(forWHIP bool, streamKey string, sessionId string) { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - stream, ok := streamMap[streamKey] - if !ok { - return - } - - stream.whepSessionsLock.Lock() - defer stream.whepSessionsLock.Unlock() - - if !forWHIP { - delete(stream.whepSessions, sessionId) - } else { - stream.videoTracks = slices.DeleteFunc(stream.videoTracks, func(v *videoTrack) bool { - return v.sessionId == sessionId - }) - - // A PeerConnection for a old WHIP session has gone to disconnected - // closed. Cleanup the state associated with that session, but - // don't modify the current session - if stream.sessionId != sessionId { - return - } - stream.hasWHIPClient.Store(false) - } - - // Only delete stream if all WHEP Sessions are gone and have no WHIP Client - if len(stream.whepSessions) != 0 || stream.hasWHIPClient.Load() { - return - } - - stream.whipActiveContextCancel() - delete(streamMap, streamKey) -} - -func addTrack(stream *stream, rid, sessionId string) (*videoTrack, error) { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - for i := range stream.videoTracks { - if rid == stream.videoTracks[i].rid && sessionId == stream.videoTracks[i].sessionId { - return stream.videoTracks[i], nil - } - } - - t := &videoTrack{rid: rid, sessionId: sessionId} - t.lastKeyFrameSeen.Store(time.Time{}) - stream.videoTracks = append(stream.videoTracks, t) - return t, nil -} - -func getPublicIP() string { - req, err := http.Get("http://ip-api.com/json/") - if err != nil { - log.Fatal(err) - } - defer func() { - if closeErr := req.Body.Close(); closeErr != nil { - log.Fatal(err) - } - }() - - body, err := io.ReadAll(req.Body) - if err != nil { - log.Fatal(err) - } - - ip := struct { - Query string - }{} - if err = json.Unmarshal(body, &ip); err != nil { - log.Fatal(err) - } - - if ip.Query == "" { - log.Fatal("Query entry was not populated") - } - - return ip.Query -} - -func createSettingEngine(isWHIP bool, udpMuxCache map[int]*ice.MultiUDPMuxDefault, tcpMuxCache map[string]ice.TCPMux) (settingEngine webrtc.SettingEngine) { - var ( - NAT1To1IPs []string - networkTypes []webrtc.NetworkType - udpMuxPort int - udpMuxOpts []ice.UDPMuxFromPortOption - err error - ) - - if os.Getenv("NETWORK_TYPES") != "" { - for _, networkTypeStr := range strings.Split(os.Getenv("NETWORK_TYPES"), "|") { - networkType, err := webrtc.NewNetworkType(networkTypeStr) - if err != nil { - log.Fatal(err) - } - networkTypes = append(networkTypes, networkType) - } - } else { - networkTypes = append(networkTypes, webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6) - } - - if os.Getenv("INCLUDE_PUBLIC_IP_IN_NAT_1_TO_1_IP") != "" { - NAT1To1IPs = append(NAT1To1IPs, getPublicIP()) - } - - if os.Getenv("NAT_1_TO_1_IP") != "" { - NAT1To1IPs = append(NAT1To1IPs, strings.Split(os.Getenv("NAT_1_TO_1_IP"), "|")...) - } - - natICECandidateType := webrtc.ICECandidateTypeHost - if os.Getenv("NAT_ICE_CANDIDATE_TYPE") == "srflx" { - natICECandidateType = webrtc.ICECandidateTypeSrflx - } - - if len(NAT1To1IPs) != 0 { - if err := settingEngine.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{ - External: NAT1To1IPs, - AsCandidateType: natICECandidateType, - }); err != nil { - log.Fatal(err) - } - } - - if os.Getenv("INTERFACE_FILTER") != "" { - interfaceFilter := func(i string) bool { - return i == os.Getenv("INTERFACE_FILTER") - } - - settingEngine.SetInterfaceFilter(interfaceFilter) - udpMuxOpts = append(udpMuxOpts, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter)) - } - - if isWHIP && os.Getenv("UDP_MUX_PORT_WHIP") != "" { - if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT_WHIP")); err != nil { - log.Fatal(err) - } - } else if !isWHIP && os.Getenv("UDP_MUX_PORT_WHEP") != "" { - if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT_WHEP")); err != nil { - log.Fatal(err) - } - } else if os.Getenv("UDP_MUX_PORT") != "" { - if udpMuxPort, err = strconv.Atoi(os.Getenv("UDP_MUX_PORT")); err != nil { - log.Fatal(err) - } - } - - if udpMuxPort != 0 { - udpMux, ok := udpMuxCache[udpMuxPort] - if !ok { - if udpMux, err = ice.NewMultiUDPMuxFromPort(udpMuxPort, udpMuxOpts...); err != nil { - log.Fatal(err) - } - udpMuxCache[udpMuxPort] = udpMux - } - - settingEngine.SetICEUDPMux(udpMux) - } - - if os.Getenv("TCP_MUX_ADDRESS") != "" { - tcpMux, ok := tcpMuxCache[os.Getenv("TCP_MUX_ADDRESS")] - if !ok { - tcpAddr, err := net.ResolveTCPAddr("tcp", os.Getenv("TCP_MUX_ADDRESS")) - if err != nil { - log.Fatal(err) - } - - tcpListener, err := net.ListenTCP("tcp", tcpAddr) - if err != nil { - log.Fatal(err) - } - - tcpMux = webrtc.NewICETCPMux(nil, tcpListener, 8) - tcpMuxCache[os.Getenv("TCP_MUX_ADDRESS")] = tcpMux - } - settingEngine.SetICETCPMux(tcpMux) - - if os.Getenv("TCP_MUX_FORCE") != "" { - networkTypes = []webrtc.NetworkType{webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6} - } else { - networkTypes = append(networkTypes, webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6) - } - } - - settingEngine.SetDTLSEllipticCurves(elliptic.X25519, elliptic.P384, elliptic.P256) - settingEngine.SetNetworkTypes(networkTypes) - settingEngine.DisableSRTCPReplayProtection(true) - settingEngine.DisableSRTPReplayProtection(true) - settingEngine.SetIncludeLoopbackCandidate(os.Getenv("INCLUDE_LOOPBACK_CANDIDATE") != "") - - return -} - -func PopulateMediaEngine(m *webrtc.MediaEngine) error { - for _, codec := range []webrtc.RTPCodecParameters{ - { - // nolint - RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", nil}, - PayloadType: 111, - }, - } { - if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil { - return err - } - } - - for _, codecDetails := range []struct { - payloadType uint8 - mimeType string - sdpFmtpLine string - }{ - {102, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f"}, - {104, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f"}, - {106, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"}, - {108, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f"}, - {39, webrtc.MimeTypeH264, "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f"}, - {45, webrtc.MimeTypeAV1, ""}, - {98, webrtc.MimeTypeVP9, "profile-id=0"}, - {100, webrtc.MimeTypeVP9, "profile-id=2"}, - {113, webrtc.MimeTypeH265, "level-id=93;profile-id=1;tier-flag=0;tx-mode=SRST"}, - } { - if err := m.RegisterCodec(webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: codecDetails.mimeType, - ClockRate: 90000, - Channels: 0, - SDPFmtpLine: codecDetails.sdpFmtpLine, - RTCPFeedback: videoRTCPFeedback, - }, - PayloadType: webrtc.PayloadType(codecDetails.payloadType), - }, webrtc.RTPCodecTypeVideo); err != nil { - return err - } - - if err := m.RegisterCodec(webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: "video/rtx", - ClockRate: 90000, - Channels: 0, - SDPFmtpLine: fmt.Sprintf("apt=%d", codecDetails.payloadType), - RTCPFeedback: nil, - }, - PayloadType: webrtc.PayloadType(codecDetails.payloadType + 1), - }, webrtc.RTPCodecTypeVideo); err != nil { - return err - } - } - - return nil -} - -func newPeerConnection(api *webrtc.API) (*webrtc.PeerConnection, error) { - cfg := webrtc.Configuration{} - - if stunServers := os.Getenv("STUN_SERVERS"); stunServers != "" { - for _, stunServer := range strings.Split(stunServers, "|") { - cfg.ICEServers = append(cfg.ICEServers, webrtc.ICEServer{ - URLs: []string{"stun:" + stunServer}, - }) - } - } - - return api.NewPeerConnection(cfg) -} - -func appendAnswer(in string) string { - if extraCandidate := os.Getenv("APPEND_CANDIDATE"); extraCandidate != "" { - index := strings.Index(in, "a=end-of-candidates") - in = in[:index] + extraCandidate + in[index:] - } - - return in -} - -func maybePrintOfferAnswer(sdp string, isOffer bool) string { - if os.Getenv("DEBUG_PRINT_OFFER") != "" && isOffer { - fmt.Println(sdp) - } - - if os.Getenv("DEBUG_PRINT_ANSWER") != "" && !isOffer { - fmt.Println(sdp) - } - - return sdp -} - -func Configure() { - streamMap = map[string]*stream{} +func Setup() { + manager.SessionsManager = &manager.SessionManager{} + manager.SessionsManager.Setup() + // Initialize media engine mediaEngine := &webrtc.MediaEngine{} - if err := PopulateMediaEngine(mediaEngine); err != nil { - panic(err) - } - - interceptorRegistry := &interceptor.Registry{} - if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { - log.Fatal(err) - } + codecs.RegisterCodecs(mediaEngine) + interceptorRegistry := interceptors.GetRegistry(mediaEngine) udpMuxCache := map[int]*ice.MultiUDPMuxDefault{} tcpMuxCache := map[string]ice.TCPMux{} - apiWhip = webrtc.NewAPI( - webrtc.WithMediaEngine(mediaEngine), - webrtc.WithInterceptorRegistry(interceptorRegistry), - webrtc.WithSettingEngine(createSettingEngine(true, udpMuxCache, tcpMuxCache)), - ) + initializeApiWhip(mediaEngine, udpMuxCache, tcpMuxCache, &interceptorRegistry) + initializeApiWhep(mediaEngine, udpMuxCache, tcpMuxCache, &interceptorRegistry) +} - apiWhep = webrtc.NewAPI( +func initializeApiWhip(mediaEngine *webrtc.MediaEngine, udpMuxCache map[int]*ice.MultiUDPMuxDefault, tcpMuxCache map[string]ice.TCPMux, registry *interceptor.Registry) { + manager.ApiWhip = webrtc.NewAPI( webrtc.WithMediaEngine(mediaEngine), - webrtc.WithInterceptorRegistry(interceptorRegistry), - webrtc.WithSettingEngine(createSettingEngine(false, udpMuxCache, tcpMuxCache)), + webrtc.WithInterceptorRegistry(registry), + webrtc.WithSettingEngine(GetSettingEngine(true, tcpMuxCache, udpMuxCache)), ) } -type StreamStatusVideo struct { - RID string `json:"rid"` - PacketsReceived uint64 `json:"packetsReceived"` - LastKeyFrameSeen time.Time `json:"lastKeyFrameSeen"` -} - -type StreamStatus struct { - StreamKey string `json:"streamKey"` - FirstSeenEpoch uint64 `json:"firstSeenEpoch"` - AudioPacketsReceived uint64 `json:"audioPacketsReceived"` - VideoStreams []StreamStatusVideo `json:"videoStreams"` - WHEPSessions []whepSessionStatus `json:"whepSessions"` -} - -type whepSessionStatus struct { - ID string `json:"id"` - CurrentLayer string `json:"currentLayer"` - SequenceNumber uint16 `json:"sequenceNumber"` - Timestamp uint32 `json:"timestamp"` - PacketsWritten uint64 `json:"packetsWritten"` +func initializeApiWhep(mediaEngine *webrtc.MediaEngine, udpMuxCache map[int]*ice.MultiUDPMuxDefault, tcpMuxCache map[string]ice.TCPMux, registry *interceptor.Registry) { + manager.ApiWhep = webrtc.NewAPI( + webrtc.WithMediaEngine(mediaEngine), + webrtc.WithInterceptorRegistry(registry), + webrtc.WithSettingEngine(GetSettingEngine(false, tcpMuxCache, udpMuxCache)), + ) } -func GetStreamStatuses() []StreamStatus { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - out := []StreamStatus{} - - for streamKey, stream := range streamMap { - whepSessions := []whepSessionStatus{} - stream.whepSessionsLock.Lock() - for id, whepSession := range stream.whepSessions { - currentLayer, ok := whepSession.currentLayer.Load().(string) - if !ok { - continue - } - - whepSessions = append(whepSessions, whepSessionStatus{ - ID: id, - CurrentLayer: currentLayer, - SequenceNumber: whepSession.sequenceNumber, - Timestamp: whepSession.timestamp, - PacketsWritten: whepSession.packetsWritten, - }) - } - stream.whepSessionsLock.Unlock() - - streamStatusVideo := []StreamStatusVideo{} - for _, videoTrack := range stream.videoTracks { - var lastKeyFrameSeen time.Time - if v, ok := videoTrack.lastKeyFrameSeen.Load().(time.Time); ok { - lastKeyFrameSeen = v - } +func HandleWhepPatch(sessionId, body string) error { + session, isFound := manager.SessionsManager.GetWhepSessionById(sessionId) - streamStatusVideo = append(streamStatusVideo, StreamStatusVideo{ - RID: videoTrack.rid, - PacketsReceived: videoTrack.packetsReceived.Load(), - LastKeyFrameSeen: lastKeyFrameSeen, - }) - } + if !isFound { + return errors.New("no session found") + } - out = append(out, StreamStatus{ - StreamKey: streamKey, - FirstSeenEpoch: stream.firstSeenEpoch, - AudioPacketsReceived: stream.audioPacketsReceived.Load(), - VideoStreams: streamStatusVideo, - WHEPSessions: whepSessions, - }) + session.PeerConnectionLock.Lock() + if err := patchPeerConnection(session.PeerConnection, body); err != nil { + session.PeerConnectionLock.Unlock() + return err } + session.PeerConnectionLock.Unlock() - return out + return nil } -func HandlePatch(sessionId, body string, isWHIP bool) error { - valueForKey := func(sdp, key string) string { - for _, l := range strings.Split(sdp, "\n") { - expectedPrefix := "a=" + key + ":" - if strings.HasPrefix(l, expectedPrefix) { - return strings.TrimPrefix(l, expectedPrefix) - } - } +func HandleWhipPatch(sessionId, body string) error { + session, isFound := manager.SessionsManager.GetSessionById(sessionId) - return "" + if !isFound { + return errors.New("no session found") } - var peerConnection *webrtc.PeerConnection - - streamMapLock.Lock() - if isWHIP { - if stream := streamMap[sessionId]; stream != nil { - peerConnection = stream.peerConnection.Load() - } - } else { - for _, s := range streamMap { - s.whepSessionsLock.Lock() - if whepSession := s.whepSessions[sessionId]; whepSession != nil { - peerConnection = whepSession.peerConnection - } - s.whepSessionsLock.Unlock() - } + session.Host.PeerConnectionLock.Lock() + if err := patchPeerConnection(session.Host.PeerConnection, body); err != nil { + session.Host.PeerConnectionLock.Unlock() + return err } - streamMapLock.Unlock() + session.Host.PeerConnectionLock.Unlock() - if peerConnection == nil { - return errNoPeerConnection - } + return nil +} + +func patchPeerConnection(peerConnection *webrtc.PeerConnection, body string) error { + oldUfrag := getSdpKeyValue(peerConnection.CurrentRemoteDescription().SDP, "ice-ufrag") + oldPwd := getSdpKeyValue(peerConnection.CurrentRemoteDescription().SDP, "ice-pwd") + newUfrag, newPwd := getSdpKeyValue(body, "ice-ufrag"), getSdpKeyValue(body, "ice-pwd") - oldUfrag := valueForKey(peerConnection.CurrentRemoteDescription().SDP, "ice-ufrag") - oldPwd := valueForKey(peerConnection.CurrentRemoteDescription().SDP, "ice-pwd") - newUfrag, newPwd := valueForKey(body, "ice-ufrag"), valueForKey(body, "ice-pwd") isICERestart := oldUfrag != newUfrag || oldPwd != newPwd + if isICERestart { - return errICERestartNotSupported + return errors.New("ice restart not supported") } - for _, l := range strings.Split(body, "\n") { + for line := range strings.SplitSeq(body, "\n") { expectedPrefix := "a=candidate:" - if strings.HasPrefix(l, expectedPrefix) { + + if strings.HasPrefix(line, expectedPrefix) { if err := peerConnection.AddICECandidate(webrtc.ICECandidateInit{ - Candidate: strings.TrimSpace(strings.TrimPrefix(l, "a=")), + Candidate: strings.TrimSpace(strings.TrimPrefix(line, "a=")), }); err != nil { return err } @@ -572,3 +103,15 @@ func HandlePatch(sessionId, body string, isWHIP bool) error { return nil } + +// Retrieve value by SDP key from SDP body +func getSdpKeyValue(sdp string, key string) string { + for l := range strings.SplitSeq(sdp, "\n") { + expectedPrefix := "a=" + key + ":" + if after, ok := strings.CutPrefix(l, expectedPrefix); ok { + return after + } + } + + return "" +} diff --git a/internal/webrtc/webrtc_test.go b/internal/webrtc/webrtc_test.go deleted file mode 100644 index b33ea5f8..00000000 --- a/internal/webrtc/webrtc_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package webrtc - -import ( - "context" - "strings" - "testing" - - "github.com/pion/webrtc/v4" - "github.com/stretchr/testify/require" -) - -func TestICETrickle(t *testing.T) { - Configure() - localTrack, err := webrtc.NewTrackLocalStaticSample( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "pion", - ) - require.NoError(t, err) - - peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - require.NoError(t, err) - - connectedCtx, connectedDone := context.WithCancel(context.TODO()) - peerConnection.OnConnectionStateChange(func(c webrtc.PeerConnectionState) { - if c == webrtc.PeerConnectionStateConnected { - connectedDone() - } - }) - - gatheredCtx, gatheredDone := context.WithCancel(context.TODO()) - peerConnection.OnICECandidate(func(c *webrtc.ICECandidate) { - if c == nil { - gatheredDone() - } - }) - - _, err = peerConnection.AddTrack(localTrack) - require.NoError(t, err) - - offer, err := peerConnection.CreateOffer(nil) - require.NoError(t, err) - require.NoError(t, peerConnection.SetLocalDescription(offer)) - - answer, err := WHIP(offer.SDP, testStreamKey) - require.NoError(t, err) - - noCandidateAnswer := "" - for _, l := range strings.Split(answer, "\n") { - if !strings.HasPrefix(l, "a=candidate:") { - noCandidateAnswer += l + "\n" - } - } - - require.NoError(t, peerConnection.SetRemoteDescription(webrtc.SessionDescription{ - Type: webrtc.SDPTypeAnswer, - SDP: noCandidateAnswer, - })) - - <-gatheredCtx.Done() - require.NoError(t, HandlePatch(testStreamKey, peerConnection.LocalDescription().SDP, true)) - - <-connectedCtx.Done() -} diff --git a/internal/webrtc/whep.go b/internal/webrtc/whep.go index 628ec626..5b152cde 100644 --- a/internal/webrtc/whep.go +++ b/internal/webrtc/whep.go @@ -1,135 +1,48 @@ package webrtc import ( - "encoding/json" - "errors" - "io" "log" - "sync/atomic" + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/webrtc/codecs" + "github.com/glimesh/broadcast-box/internal/webrtc/peerconnection" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" + "github.com/glimesh/broadcast-box/internal/webrtc/utils" "github.com/google/uuid" - "github.com/pion/rtcp" - "github.com/pion/rtp" "github.com/pion/webrtc/v4" ) -type ( - whepSession struct { - videoTrack *trackMultiCodec - currentLayer atomic.Value - waitingForKeyframe atomic.Bool - sequenceNumber uint16 - timestamp uint32 - packetsWritten uint64 - peerConnection *webrtc.PeerConnection - } - - simulcastLayerResponse struct { - EncodingId string `json:"encodingId"` - } -) - -func WHEPLayers(whepSessionId string) ([]byte, error) { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - layers := []simulcastLayerResponse{} - for streamKey := range streamMap { - streamMap[streamKey].whepSessionsLock.Lock() - defer streamMap[streamKey].whepSessionsLock.Unlock() - - if _, ok := streamMap[streamKey].whepSessions[whepSessionId]; ok { - for i := range streamMap[streamKey].videoTracks { - layers = append(layers, simulcastLayerResponse{EncodingId: streamMap[streamKey].videoTracks[i].rid}) - } - - break - } - } +func WHEP(offer string, streamKey string) (string, string, error) { + utils.DebugOutputOffer(offer) - resp := map[string]map[string][]simulcastLayerResponse{ - "1": map[string][]simulcastLayerResponse{ - "layers": layers, - }, + profile := authorization.PublicProfile{ + StreamKey: streamKey, } - return json.Marshal(resp) -} - -func WHEPChangeLayer(whepSessionId, layer string) error { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - for streamKey := range streamMap { - streamMap[streamKey].whepSessionsLock.Lock() - defer streamMap[streamKey].whepSessionsLock.Unlock() - - if _, ok := streamMap[streamKey].whepSessions[whepSessionId]; ok { - streamMap[streamKey].whepSessions[whepSessionId].currentLayer.Store(layer) - streamMap[streamKey].whepSessions[whepSessionId].waitingForKeyframe.Store(true) - streamMap[streamKey].pliChan <- true - } - } - - return nil -} - -func WHEP(offer, streamKey string) (string, string, error) { - maybePrintOfferAnswer(offer, true) - - streamMapLock.Lock() - defer streamMapLock.Unlock() - stream, err := getStream(streamKey, "") + session, err := manager.SessionsManager.GetOrAddSession(profile, false) if err != nil { return "", "", err } whepSessionId := uuid.New().String() - videoTrack := &trackMultiCodec{id: "video", streamID: "pion"} - - peerConnection, err := newPeerConnection(apiWhep) + peerConnection, err := peerconnection.CreateWhepPeerConnection() if err != nil { return "", "", err } - peerConnection.OnICEConnectionStateChange(func(i webrtc.ICEConnectionState) { - if i == webrtc.ICEConnectionStateFailed || i == webrtc.ICEConnectionStateClosed { - if err := peerConnection.Close(); err != nil { - log.Println(err) - } - - peerConnectionDisconnected(false, streamKey, whepSessionId) - } - }) + audioTrack, videoTrack := codecs.GetDefaultTracks(streamKey) - if _, err = peerConnection.AddTrack(stream.audioTrack); err != nil { + _, err = peerConnection.AddTrack(audioTrack) + if err != nil { return "", "", err } - rtpSender, err := peerConnection.AddTrack(videoTrack) + videoRtcpSender, err := peerConnection.AddTrack(videoTrack) if err != nil { return "", "", err } - go func() { - for { - rtcpPackets, _, rtcpErr := rtpSender.ReadRTCP() - if rtcpErr != nil { - return - } - - for _, r := range rtcpPackets { - if _, isPLI := r.(*rtcp.PictureLossIndication); isPLI { - select { - case stream.pliChan <- true: - default: - } - } - } - } - }() - if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{ SDP: offer, Type: webrtc.SDPTypeOffer, @@ -146,48 +59,15 @@ func WHEP(offer, streamKey string) (string, string, error) { return "", "", err } - <-gatherComplete - - stream.whepSessionsLock.Lock() - defer stream.whepSessionsLock.Unlock() - - stream.whepSessions[whepSessionId] = &whepSession{ - videoTrack: videoTrack, - timestamp: 50000, - peerConnection: peerConnection, - } - stream.whepSessions[whepSessionId].currentLayer.Store("") - stream.whepSessions[whepSessionId].waitingForKeyframe.Store(false) - - return maybePrintOfferAnswer(appendAnswer(peerConnection.LocalDescription().SDP), false), whepSessionId, nil -} - -func (w *whepSession) sendVideoPacket(rtpPkt *rtp.Packet, layer string, timeDiff int64, sequenceDiff int, codec videoTrackCodec, isKeyframe bool) { - // Skip if video track is not available (e.g., audio-only) - if w.videoTrack == nil || w.videoTrack.writeStream == nil { - return - } - - if w.currentLayer.Load() == "" { - w.currentLayer.Store(layer) - } else if layer != w.currentLayer.Load() { - return - } else if w.waitingForKeyframe.Load() { - if !isKeyframe { - return - } - - w.waitingForKeyframe.Store(false) + // TODO: Should this be before gatherComplete to assure registered events are triggered at correct time? + if err := session.AddWhep(whepSessionId, peerConnection, audioTrack, videoTrack, videoRtcpSender); err != nil { + return "", "", err } - w.packetsWritten += 1 - w.sequenceNumber = uint16(int(w.sequenceNumber) + sequenceDiff) - w.timestamp = uint32(int64(w.timestamp) + timeDiff) - - rtpPkt.SequenceNumber = w.sequenceNumber - rtpPkt.Timestamp = w.timestamp + <-gatherComplete + log.Println("WhepSession.GatheringCompletePromise: Completed Gathering for", streamKey) - if err := w.videoTrack.WriteRTP(rtpPkt, codec); err != nil && !errors.Is(err, io.ErrClosedPipe) { - log.Println(err) - } + return utils.DebugOutputAnswer(utils.AppendCandidateToAnswer(peerConnection.LocalDescription().SDP)), + whepSessionId, + nil } diff --git a/internal/webrtc/whep_test.go b/internal/webrtc/whep_test.go deleted file mode 100644 index 7a4f4971..00000000 --- a/internal/webrtc/whep_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package webrtc - -import "testing" - -func TestAudioOnly(t *testing.T) { - session := &whepSession{ - videoTrack: nil, - timestamp: 50000, - } - - session.sendVideoPacket(nil, "", 0, 0, 0, true) -} diff --git a/internal/webrtc/whip.go b/internal/webrtc/whip.go index df0f2792..47796516 100644 --- a/internal/webrtc/whip.go +++ b/internal/webrtc/whip.go @@ -2,196 +2,41 @@ package webrtc import ( "errors" - "io" "log" - "math" - "strings" - "time" - "github.com/google/uuid" - "github.com/pion/rtcp" - "github.com/pion/rtp" - "github.com/pion/rtp/codecs" - "github.com/pion/webrtc/v4" + "github.com/glimesh/broadcast-box/internal/server/authorization" + "github.com/glimesh/broadcast-box/internal/webrtc/peerconnection" + "github.com/glimesh/broadcast-box/internal/webrtc/sessions/manager" + "github.com/glimesh/broadcast-box/internal/webrtc/utils" ) -func audioWriter(remoteTrack *webrtc.TrackRemote, stream *stream) { - rtpBuf := make([]byte, 1500) - for { - rtpRead, _, err := remoteTrack.Read(rtpBuf) - switch { - case errors.Is(err, io.EOF): - return - case err != nil: - log.Println(err) - return - } +// Initialize WHIP session for incoming stream +func WHIP(offer string, profile authorization.PublicProfile) (sdp string, sessionId string, err error) { + log.Println("WHIP.Offer.Requested", profile.StreamKey, profile.MOTD) - stream.audioPacketsReceived.Add(1) - if _, writeErr := stream.audioTrack.Write(rtpBuf[:rtpRead]); writeErr != nil && !errors.Is(writeErr, io.ErrClosedPipe) { - log.Println(writeErr) - return - } - } -} - -func videoWriter(remoteTrack *webrtc.TrackRemote, stream *stream, peerConnection *webrtc.PeerConnection, s *stream, sessionId string) { - id := remoteTrack.RID() - if id == "" { - id = videoTrackLabelDefault + if err := utils.ValidateOffer(offer); err != nil { + return "", "", errors.New("invalid offer: " + err.Error()) } - videoTrack, err := addTrack(s, id, sessionId) + session, err := manager.SessionsManager.GetOrAddSession(profile, true) if err != nil { - log.Println(err) - return + return "", "", err } - go func() { - for { - select { - case <-stream.whipActiveContext.Done(): - return - case <-stream.pliChan: - if sendErr := peerConnection.WriteRTCP([]rtcp.Packet{ - &rtcp.PictureLossIndication{ - MediaSSRC: uint32(remoteTrack.SSRC()), - }, - }); sendErr != nil { - return - } - } - } - }() - - rtpBuf := make([]byte, 1500) - rtpPkt := &rtp.Packet{} - codec := getVideoTrackCodec(remoteTrack.Codec().MimeType) - - var depacketizer rtp.Depacketizer - switch codec { - case videoTrackCodecH264: - depacketizer = &codecs.H264Packet{} - case videoTrackCodecVP8: - depacketizer = &codecs.VP8Packet{} - case videoTrackCodecVP9: - depacketizer = &codecs.VP9Packet{} - } - - lastTimestamp := uint32(0) - lastTimestampSet := false - - lastSequenceNumber := uint16(0) - lastSequenceNumberSet := false - - for { - rtpRead, _, err := remoteTrack.Read(rtpBuf) - switch { - case errors.Is(err, io.EOF): - return - case err != nil: - log.Println(err) - return - } - - if err = rtpPkt.Unmarshal(rtpBuf[:rtpRead]); err != nil { - log.Println(err) - return - } - - videoTrack.packetsReceived.Add(1) - - // Keyframe detection has only been implemented for H264 - isKeyframe := isKeyframe(rtpPkt, codec, depacketizer) - if isKeyframe && codec == videoTrackCodecH264 { - videoTrack.lastKeyFrameSeen.Store(time.Now()) - } - - rtpPkt.Extension = false - rtpPkt.Extensions = nil - - timeDiff := int64(rtpPkt.Timestamp) - int64(lastTimestamp) - switch { - case !lastTimestampSet: - timeDiff = 0 - lastTimestampSet = true - case timeDiff < -(math.MaxUint32 / 10): - timeDiff += (math.MaxUint32 + 1) - } - - sequenceDiff := int(rtpPkt.SequenceNumber) - int(lastSequenceNumber) - switch { - case !lastSequenceNumberSet: - lastSequenceNumberSet = true - sequenceDiff = 0 - case sequenceDiff < -(math.MaxUint16 / 10): - sequenceDiff += (math.MaxUint16 + 1) - } - - lastTimestamp = rtpPkt.Timestamp - lastSequenceNumber = rtpPkt.SequenceNumber - - s.whepSessionsLock.RLock() - for i := range s.whepSessions { - s.whepSessions[i].sendVideoPacket(rtpPkt, id, timeDiff, sequenceDiff, codec, isKeyframe) - } - s.whepSessionsLock.RUnlock() - - } -} - -func WHIP(offer, streamKey string) (string, error) { - maybePrintOfferAnswer(offer, true) - - whipSessionId := uuid.New().String() - - peerConnection, err := newPeerConnection(apiWhip) - if err != nil { - return "", err - } - - streamMapLock.Lock() - defer streamMapLock.Unlock() - stream, err := getStream(streamKey, whipSessionId) + peerConnection, err := peerconnection.CreateWhipPeerConnection(offer) if err != nil { - return "", err - } - stream.peerConnection.Store(peerConnection) - - peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) { - if strings.HasPrefix(remoteTrack.Codec().MimeType, "audio") { - audioWriter(remoteTrack, stream) - } else { - videoWriter(remoteTrack, stream, peerConnection, stream, whipSessionId) - - } - }) - - peerConnection.OnICEConnectionStateChange(func(i webrtc.ICEConnectionState) { - if i == webrtc.ICEConnectionStateFailed || i == webrtc.ICEConnectionStateClosed { - if err := peerConnection.Close(); err != nil { - log.Println(err) - } - peerConnectionDisconnected(true, streamKey, whipSessionId) - } - }) - - if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{ - SDP: string(offer), - Type: webrtc.SDPTypeOffer, - }); err != nil { - return "", err + log.Println("WHIP.CreateWhipPeerConnection.Failed", err) + peerConnection.Close() + return "", "", err } - gatherComplete := webrtc.GatheringCompletePromise(peerConnection) - answer, err := peerConnection.CreateAnswer(nil) - - if err != nil { - return "", err - } else if err = peerConnection.SetLocalDescription(answer); err != nil { - return "", err + if err := session.AddHost(peerConnection); err != nil { + return "", "", err } - <-gatherComplete - return maybePrintOfferAnswer(appendAnswer(peerConnection.LocalDescription().SDP), false), nil + sdp = utils.DebugOutputAnswer(utils.AppendCandidateToAnswer(peerConnection.LocalDescription().SDP)) + sessionId = session.Host.Id + err = nil + log.Println("WHIP.Offer.Accepted", profile.StreamKey, profile.MOTD) + return } diff --git a/internal/webrtc/whip_test.go b/internal/webrtc/whip_test.go deleted file mode 100644 index 32059c03..00000000 --- a/internal/webrtc/whip_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package webrtc - -import ( - "context" - "testing" - "time" - - "github.com/pion/webrtc/v4" - "github.com/stretchr/testify/require" -) - -const testStreamKey = "test" - -func doesWHIPSessionExist() (ok bool) { - streamMapLock.Lock() - defer streamMapLock.Unlock() - - _, ok = streamMap[testStreamKey] - return -} - -// Asserts that a old PeerConnection doesn't destroy the new one -// when it disconnects -func TestReconnect(t *testing.T) { - Configure() - localTrack, err := webrtc.NewTrackLocalStaticSample( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "pion", - ) - require.NoError(t, err) - - // Create the first WHIP Session - firstPublisherConnected, firstPublisherConnectedDone := context.WithCancel(context.TODO()) - - firstPublisher, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - require.NoError(t, err) - - firstPublisher.OnConnectionStateChange(func(c webrtc.PeerConnectionState) { - if c == webrtc.PeerConnectionStateConnected { - firstPublisherConnectedDone() - - } - }) - - _, err = firstPublisher.AddTrack(localTrack) - require.NoError(t, err) - - offer, err := firstPublisher.CreateOffer(nil) - require.NoError(t, err) - require.NoError(t, firstPublisher.SetLocalDescription(offer)) - - answer, err := WHIP(offer.SDP, testStreamKey) - require.NoError(t, err) - - require.NoError(t, firstPublisher.SetRemoteDescription(webrtc.SessionDescription{ - Type: webrtc.SDPTypeAnswer, - SDP: answer, - })) - - require.True(t, doesWHIPSessionExist()) - <-firstPublisherConnected.Done() - - // Create the second WHIP Session - secondPublisherConnected, secondPublisherConnectedDone := context.WithCancel(context.TODO()) - - secondPublisher, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - require.NoError(t, err) - - secondPublisher.OnConnectionStateChange(func(c webrtc.PeerConnectionState) { - if c == webrtc.PeerConnectionStateConnected { - secondPublisherConnectedDone() - - } - }) - - _, err = secondPublisher.AddTrack(localTrack) - require.NoError(t, err) - - offer, err = secondPublisher.CreateOffer(nil) - require.NoError(t, err) - require.NoError(t, secondPublisher.SetLocalDescription(offer)) - - answer, err = WHIP(offer.SDP, testStreamKey) - require.NoError(t, err) - - require.NoError(t, secondPublisher.SetRemoteDescription(webrtc.SessionDescription{ - Type: webrtc.SDPTypeAnswer, - SDP: answer, - })) - - require.True(t, doesWHIPSessionExist()) - <-secondPublisherConnected.Done() - - // Close the first WHIP Session, the session must still exist - require.NoError(t, firstPublisher.Close()) - time.Sleep(time.Second) - require.True(t, doesWHIPSessionExist()) - - // Close the second WHIP Session, the session must be gone - require.NoError(t, secondPublisher.Close()) - time.Sleep(time.Second) - require.False(t, doesWHIPSessionExist()) -} diff --git a/main.go b/main.go index 255e4158..34bcebf2 100644 --- a/main.go +++ b/main.go @@ -1,353 +1,41 @@ package main import ( - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" "log" - "mime" - "net/http" "os" - "path" - "path/filepath" - "regexp" + "runtime" "strings" "time" - "github.com/glimesh/broadcast-box/internal/networktest" - "github.com/glimesh/broadcast-box/internal/webhook" + "github.com/glimesh/broadcast-box/internal/console" + "github.com/glimesh/broadcast-box/internal/environment" + "github.com/glimesh/broadcast-box/internal/server" + "github.com/glimesh/broadcast-box/internal/test" "github.com/glimesh/broadcast-box/internal/webrtc" - "github.com/joho/godotenv" -) - -const ( - envFileProd = ".env.production" - envFileDev = ".env.development" - - networkTestIntroMessage = "\033[0;33mNETWORK_TEST_ON_START is enabled. If the test fails Broadcast Box will exit.\nSee the README for how to debug or disable NETWORK_TEST_ON_START\033[0m" - networkTestSuccessMessage = "\033[0;32mNetwork Test passed.\nHave fun using Broadcast Box.\033[0m" - networkTestFailedMessage = "\033[0;31mNetwork Test failed.\n%s\nPlease see the README and join Discord for help\033[0m" -) - -var ( - errNoBuildDirectoryErr = errors.New("\033[0;31mBuild directory does not exist, run `npm install` and `npm run build` in the web directory.\033[0m") - errAuthorizationNotSet = errors.New("authorization was not set") - errInvalidStreamKey = errors.New("invalid stream key format") - - streamKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.~]+$`) -) -type ( - whepLayerRequestJSON struct { - MediaId string `json:"mediaId"` - EncodingId string `json:"encodingId"` - } + "net/http" + _ "net/http/pprof" ) -func getStreamKey(action string, r *http.Request) (streamKey string, err error) { - authorizationHeader := r.Header.Get("Authorization") - if authorizationHeader == "" { - return "", errAuthorizationNotSet - } - - const bearerPrefix = "Bearer " - if !strings.HasPrefix(authorizationHeader, bearerPrefix) { - return "", errInvalidStreamKey - } - - streamKey = strings.TrimPrefix(authorizationHeader, bearerPrefix) - if webhookUrl := os.Getenv("WEBHOOK_URL"); webhookUrl != "" { - streamKey, err = webhook.CallWebhook(webhookUrl, action, streamKey, r) - if err != nil { - return "", err - } - } - - if !streamKeyRegex.MatchString(streamKey) { - return "", errInvalidStreamKey - } - - return streamKey, nil -} - -func logHTTPError(w http.ResponseWriter, err string, code int) { - log.Println(err) - http.Error(w, err, code) -} - -func patchHandler(res http.ResponseWriter, r *http.Request, sessionId, body string, isWHIP bool) { - mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) - if err != nil || mediaType != "application/trickle-ice-sdpfrag" { - logHTTPError(res, "invalid content type", http.StatusUnsupportedMediaType) - return - } - - if err = webrtc.HandlePatch(sessionId, body, isWHIP); err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - res.WriteHeader(http.StatusNoContent) -} - -func whipHandler(res http.ResponseWriter, r *http.Request) { - if r.Method != "POST" && r.Method != "PATCH" { - return - } - - streamKey, err := getStreamKey("whip-connect", r) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - if r.Method == "PATCH" { - patchHandler(res, r, streamKey, string(body), true) - return - } - - answer, err := webrtc.WHIP(string(body), streamKey) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - res.Header().Add("Location", "/api/whip") - res.Header().Add("Content-Type", "application/sdp") - res.WriteHeader(http.StatusCreated) - if _, err = fmt.Fprint(res, answer); err != nil { - log.Println(err) - } -} - -func whepHandler(res http.ResponseWriter, req *http.Request) { - if req.Method != "POST" && req.Method != "PATCH" { - return - } - - streamKey, err := getStreamKey("whep-connect", req) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - body, err := io.ReadAll(req.Body) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - if req.Method == "PATCH" { - patchHandler(res, req, "TODO", string(body), true) - return - } - - answer, whepSessionId, err := webrtc.WHEP(string(body), streamKey) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - apiPath := req.Host + strings.TrimSuffix(req.URL.RequestURI(), "whep") - res.Header().Add("Link", `<`+apiPath+"sse/"+whepSessionId+`>; rel="urn:ietf:params:whep:ext:core:server-sent-events"; events="layers"`) - res.Header().Add("Link", `<`+apiPath+"layer/"+whepSessionId+`>; rel="urn:ietf:params:whep:ext:core:layer"`) - res.Header().Add("Location", "/api/whep/"+whepSessionId) - res.Header().Add("Content-Type", "application/sdp") - res.WriteHeader(http.StatusCreated) - if _, err = fmt.Fprint(res, answer); err != nil { - log.Println(err) - } -} - -func whepServerSentEventsHandler(res http.ResponseWriter, req *http.Request) { - res.Header().Set("Content-Type", "text/event-stream") - res.Header().Set("Cache-Control", "no-cache") - res.Header().Set("Connection", "keep-alive") - - vals := strings.Split(req.URL.RequestURI(), "/") - whepSessionId := vals[len(vals)-1] - - layers, err := webrtc.WHEPLayers(whepSessionId) - if err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - if _, err = fmt.Fprintf(res, "event: layers\ndata: %s\n\n\n", string(layers)); err != nil { - log.Println(err) - } -} - -func whepLayerHandler(res http.ResponseWriter, req *http.Request) { - var r whepLayerRequestJSON - if err := json.NewDecoder(req.Body).Decode(&r); err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } - - vals := strings.Split(req.URL.RequestURI(), "/") - whepSessionId := vals[len(vals)-1] - - if err := webrtc.WHEPChangeLayer(whepSessionId, r.EncodingId); err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - return - } -} - -func statusHandler(res http.ResponseWriter, req *http.Request) { - if os.Getenv("DISABLE_STATUS") != "" { - logHTTPError(res, "Status Service Unavailable", http.StatusServiceUnavailable) - return - } - - res.Header().Add("Content-Type", "application/json") - - if err := json.NewEncoder(res).Encode(webrtc.GetStreamStatuses()); err != nil { - logHTTPError(res, err.Error(), http.StatusBadRequest) - } -} - -func indexHTMLWhenNotFound(fs http.FileSystem) http.Handler { - fileServer := http.FileServer(fs) - - return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - _, err := fs.Open(path.Clean(req.URL.Path)) // Do not allow path traversals. - if errors.Is(err, os.ErrNotExist) { - http.ServeFile(resp, req, "./web/build/index.html") - - return - } - fileServer.ServeHTTP(resp, req) - }) -} - -func corsHandler(next func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { - return func(res http.ResponseWriter, req *http.Request) { - res.Header().Set("Access-Control-Allow-Origin", "*") - res.Header().Set("Access-Control-Allow-Methods", "*") - res.Header().Set("Access-Control-Allow-Headers", "*") - res.Header().Set("Access-Control-Expose-Headers", "*") - - if req.Method != http.MethodOptions { - next(res, req) - } - } -} - -func loadConfigs() error { - if os.Getenv("APP_ENV") == "development" { - log.Println("Loading `" + envFileDev + "`") - return godotenv.Load(envFileDev) - } else { - log.Println("Loading `" + envFileProd + "`") - if err := godotenv.Load(envFileProd); err != nil { - return err - } - - if _, err := os.Stat("./web/build"); os.IsNotExist(err) && os.Getenv("DISABLE_FRONTEND") == "" { - return errNoBuildDirectoryErr - } - - return nil - } -} - func main() { - if err := loadConfigs(); err != nil { - log.Println("Failed to find config in CWD, changing CWD to executable path") - - exePath, err := os.Executable() - if err != nil { - log.Fatal(err) - } - - if err = os.Chdir(filepath.Dir(exePath)); err != nil { - log.Fatal(err) - } - - if err = loadConfigs(); err != nil { - log.Fatal(err) - } - } - - webrtc.Configure() - - if os.Getenv("NETWORK_TEST_ON_START") == "true" { - fmt.Println(networkTestIntroMessage) //nolint - - go func() { - time.Sleep(time.Second * 5) - - if networkTestErr := networktest.Run(whepHandler); networkTestErr != nil { - fmt.Printf(networkTestFailedMessage, networkTestErr.Error()) - os.Exit(1) - } else { - fmt.Println(networkTestSuccessMessage) //nolint - } - }() - } + environment.SetupLogger() + environment.LoadEnvironmentVariables() + console.HandleConsoleFlags() - httpsRedirectPort := "80" - if val := os.Getenv("HTTPS_REDIRECT_PORT"); val != "" { - httpsRedirectPort = val - } - - if os.Getenv("HTTPS_REDIRECT_PORT") != "" || os.Getenv("ENABLE_HTTP_REDIRECT") != "" { + if shouldProfileApplication := os.Getenv(environment.ENABLE_PROFILING); strings.EqualFold(shouldProfileApplication, "true") { go func() { - redirectServer := &http.Server{ - Addr: ":" + httpsRedirectPort, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently) - }), - } - - log.Println("Running HTTP->HTTPS redirect Server at :" + httpsRedirectPort) - log.Fatal(redirectServer.ListenAndServe()) + runtime.SetBlockProfileRate(1) + runtime.SetMutexProfileFraction(1) + log.Println(http.ListenAndServe("localhost:6060", nil)) }() } - mux := http.NewServeMux() - if os.Getenv("DISABLE_FRONTEND") == "" { - mux.Handle("/", indexHTMLWhenNotFound(http.Dir("./web/build"))) - } - mux.HandleFunc("/api/whip", corsHandler(whipHandler)) - mux.HandleFunc("/api/whep", corsHandler(whepHandler)) - mux.HandleFunc("/api/sse/", corsHandler(whepServerSentEventsHandler)) - mux.HandleFunc("/api/layer/", corsHandler(whepLayerHandler)) - mux.HandleFunc("/api/status", corsHandler(statusHandler)) + log.Println("Booting up Broadcast", time.Now().Format("2006-01-02 15:04:05")) + webrtc.Setup() - server := &http.Server{ - Handler: mux, - Addr: os.Getenv("HTTP_ADDRESS"), + if shouldNetworkTest := os.Getenv(environment.NETWORK_TEST_ON_START); strings.EqualFold(shouldNetworkTest, "true") { + networktest.RunNetworkTest() } - tlsKey := os.Getenv("SSL_KEY") - tlsCert := os.Getenv("SSL_CERT") - - if tlsKey != "" && tlsCert != "" { - server.TLSConfig = &tls.Config{ - Certificates: []tls.Certificate{}, - } - - cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) - if err != nil { - log.Fatal(err) - } - - server.TLSConfig.Certificates = append(server.TLSConfig.Certificates, cert) - - log.Println("Running HTTPS Server at `" + os.Getenv("HTTP_ADDRESS") + "`") - log.Fatal(server.ListenAndServeTLS("", "")) - } else { - log.Println("Running HTTP Server at `" + os.Getenv("HTTP_ADDRESS") + "`") - log.Fatal(server.ListenAndServe()) - } + server.StartWebServer() } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9e2d9a61 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "broadcast-box", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 00000000..92d95d7e --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,44 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + globals: { + ...globals.browser, + ...globals.node, + }, + }, + plugins: { + react, + "react-hooks": reactHooks, + "@typescript-eslint": tseslint, + }, + rules: { + // React rules + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + // TypeScript rules + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + }, + settings: { + react: { version: "detect" }, + }, + }, +]; + diff --git a/web/package-lock.json b/web/package-lock.json index 7e212c72..df1bd247 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,2975 +1,5464 @@ -{ - "name": "broadcast-box", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "broadcast-box", - "version": "0.1.0", - "dependencies": { - "@heroicons/react": "^2.2.0", - "@web3-storage/parse-link-header": "^3.1.0", - "react": "^19.1.1", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@tailwindcss/postcss": "^4.1.18", - "@tailwindcss/vite": "^4.1.17", - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", - "postcss": "^8.5.6", - "react-router-dom": "^7.12.0", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "vite": "^7.2.7" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", - "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", - "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", - "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.18.6", - "@babel/helper-create-class-features-plugin": "^7.21.0", - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@heroicons/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", - "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16 || ^19.0.0-rc" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" - } - }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@web3-storage/parse-link-header": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@web3-storage/parse-link-header/-/parse-link-header-3.1.0.tgz", - "integrity": "sha512-K1undnK70vLLauqdE8bq/l98isTF2FDhcP0UPpXVSjkSWe3xhAn5eRXk5jfA1E5ycNm84Ws/rQFUD7ue11nciw==", - "license": "MIT" - }, - "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001720", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", - "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.161", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", - "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", - "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", - "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-router": "7.12.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/rollup": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} +{ + "name": "broadcast-box", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "broadcast-box", + "version": "0.1.0", + "dependencies": { + "@heroicons/react": "^2.2.0", + "@web3-storage/parse-link-header": "^3.1.0", + "dotenv": "^17.2.3", + "react": "^19.1.1", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^8.48.1", + "@typescript-eslint/parser": "^8.48.1", + "@vitejs/plugin-basic-ssl": "^2.1.0", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "react-router-dom": "^7.6.2", + "tailwindcss": "^4.1.10", + "typescript": "^5.8.3", + "vite": "^6.2.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.9", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@web3-storage/parse-link-header": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.278", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "dev": true, + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "dev": true, + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json index 5823670d..85896829 100644 --- a/web/package.json +++ b/web/package.json @@ -1,49 +1,57 @@ -{ - "name": "broadcast-box", - "version": "0.1.0", - "private": true, - "type": "module", - "dependencies": { - "@heroicons/react": "^2.2.0", - "@web3-storage/parse-link-header": "^3.1.0", - "react": "^19.1.1", - "react-dom": "^19.2.3" - }, - "scripts": { - "start": "vite", - "host": "vite --host", - "build": "vite build", - "lint": "eslint ./src --max-warnings 0" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@tailwindcss/postcss": "^4.1.18", - "@tailwindcss/vite": "^4.1.17", - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", - "postcss": "^8.5.6", - "react-router-dom": "^7.12.0", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "vite": "^7.2.7" - } -} +{ + "name": "broadcast-box", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@heroicons/react": "^2.2.0", + "@web3-storage/parse-link-header": "^3.1.0", + "dotenv": "^17.2.3", + "react": "^19.1.1", + "react-dom": "^19.2.3" + }, + "scripts": { + "start": "vite", + "host": "vite --host", + "build": "vite build", + "lint": "eslint ./src --ext .ts,.tsx --fix --max-warnings 0" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^8.48.1", + "@typescript-eslint/parser": "^8.48.1", + "@vitejs/plugin-basic-ssl": "^2.1.0", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "react-router-dom": "^7.6.2", + "tailwindcss": "^4.1.10", + "typescript": "^5.8.3", + "vite": "^6.2.1" + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 00000000..0c486512 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 0dfbf707..96ae2c28 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,23 +1,25 @@ -import React from 'react' -import { Routes, Route } from 'react-router-dom' - -import BrowserBroadcaster from "./components/broadcast/Broadcast"; -import PlayerPage from "./components/player/PlayerPage"; -import RootWrapper from "./components/rootWrapper/RootWrapper"; -import Frontpage from "./components/selection/Frontpage"; -import Statistics from "./components/statistics/Statistics"; - -function App() { - return ( - - }> - } /> - } /> - } /> - } /> - - - ) -} - -export default App \ No newline at end of file +import React from "react"; +import { Routes, Route } from "react-router-dom"; + +import BrowserBroadcaster from "./components/broadcast/Broadcast"; +import PlayerPage from "./components/player/PlayerPage"; +import RootWrapper from "./components/rootWrapper/RootWrapper"; +import Frontpage from "./components/selection/Frontpage"; +import Statistics from "./components/statistics/Statistics"; +import Admin from "./components/admin/Login"; + +function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/web/src/components/admin/Frontpage.tsx b/web/src/components/admin/Frontpage.tsx new file mode 100644 index 00000000..bc2561fb --- /dev/null +++ b/web/src/components/admin/Frontpage.tsx @@ -0,0 +1,57 @@ +import React, { useContext, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import Card from "../shared/Card"; +import Button from "../shared/Button"; +import StatusPage from "./menus/StatusPage"; +import ProfilesPage from "./menus/ProfilesPage"; +import ApiPage from "./menus/ApiPage"; +import LoggingPage from "./menus/LoggingPage"; +import { LocaleContext } from "../../providers/LocaleProvider"; + +const ADMIN_TOKEN = "adminToken"; +const AdminFrontpage = () => { + const navigation = useNavigate(); + const { locale } = useContext(LocaleContext) + const [currentMenu, setCurrentMenu] = useState("Status"); + + const onChangeMenu = (name: string) => setCurrentMenu(() => name); + const logout = () => { + localStorage.removeItem(ADMIN_TOKEN); + navigation("/"); + }; + + return ( +
+

{locale.admin_page.title}

+ +
+
+ +
+
+ +
+
+
+
+ +
+ + {currentMenu === "Status" && } + {currentMenu === "Profiles" && } + {currentMenu === "API" && } + {currentMenu === "Logging" && } + +
+
+
+ ); +}; + +export default AdminFrontpage; diff --git a/web/src/components/admin/Login.tsx b/web/src/components/admin/Login.tsx new file mode 100644 index 00000000..cc00b9fa --- /dev/null +++ b/web/src/components/admin/Login.tsx @@ -0,0 +1,109 @@ +import React, { useContext, useEffect, useState } from "react"; +import TextInputDialog from "../shared/TextInputDialog"; +import AdminFrontpage from "./Frontpage"; +import { LocaleContext } from "../../providers/LocaleProvider"; +import toBase64Utf8 from "../../utilities/base64"; + +const ADMIN_TOKEN = "adminToken"; + +interface LoginResponse { + isValid: boolean; + errorMessage: string; +} + +const Admin = () => { + const { locale } = useContext(LocaleContext) + const [errorMessage, setErrorMessage] = useState(""); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + const login = (token: string) => { + fetch(`/api/admin/login`, { + method: "POST", + headers: { + Authorization: `Bearer ${toBase64Utf8(token)}`, + }, + }) + .then((result) => { + if (result.status > 400 && result.status < 500) { + setErrorMessage("Invalid login"); + return; + } + + return result.json(); + }) + .then((result: LoginResponse) => { + if (result.isValid) { + localStorage.setItem(ADMIN_TOKEN, token); + setIsLoggedIn(() => true); + } else { + localStorage.removeItem(ADMIN_TOKEN); + setErrorMessage(() => result.errorMessage); + } + }); + }; + + useEffect(() => { + const token = localStorage.getItem(ADMIN_TOKEN); + + if (!token) { + return; + } + + fetch(`/api/admin/login`, { + method: "POST", + headers: { + Authorization: `Bearer ${toBase64Utf8(token)}`, + }, + }) + .then((result) => result.json()) + .then((result: LoginResponse) => { + if (result.isValid) { + setIsLoggedIn(true); + } + }); + }, []); + + if (isLoggedIn) { + return ; + } + + return ( +
+ + isSecret={true} + title={locale.admin_login.login_input_dialog_title} + message={locale.admin_login.login_input_dialog_message} + placeholder={locale.admin_login.login_input_dialog_placeholder} + canCloseOnBackgroundClick={false} + onAccept={(result: string) => login(result)} + buttonAcceptText={locale.admin_login.button_login_text} + /> + + {errorMessage !== "" && ( +
+
+ + + +

+ {locale.admin_login.error_message_login_failed} +

+
+

{errorMessage}

+
+ )} +
+ ); +}; + +export default Admin; diff --git a/web/src/components/admin/menus/ApiPage.tsx b/web/src/components/admin/menus/ApiPage.tsx new file mode 100644 index 00000000..87beb5c3 --- /dev/null +++ b/web/src/components/admin/menus/ApiPage.tsx @@ -0,0 +1,65 @@ +import React, { useContext, useState } from "react"; +import { LocaleContext } from "../../../providers/LocaleProvider"; + +// const ADMIN_TOKEN = "adminToken"; + +interface ApiSettingsResult { + name: string + value: string +} + +const ApiPage = () => { + const { locale } = useContext(LocaleContext) + const [response, setResponse] = useState() + + // const refreshStatus = () => { + // fetch(`/api/admin/status`, { + // method: "GET", + // headers: { + // Authorization: `Bearer ${localStorage.getItem(ADMIN_TOKEN)}`, + // }, + // }) + // .then((result) => { + // if (result.status > 400 && result.status < 500) { + // localStorage.removeItem(ADMIN_TOKEN) + // return; + // } + // return result.json(); + // }) + // .then((result) => { + // setResponse(() => result) + // }); + // }; + + // useEffect(() => { + // refreshStatus() + // }, []) + + return ( +
+

{locale.admin_page_api.title}

+ +
+ + + + + + + + + {response?.map((setting, index) => { + return ( + + + + + ); + })} + +
{locale.admin_page_api.table_header_setting_name}{locale.admin_page_api.table_header_value}
{setting.name}{setting.value}
+
+
+ ); +} +export default ApiPage; diff --git a/web/src/components/admin/menus/LoggingPage.tsx b/web/src/components/admin/menus/LoggingPage.tsx new file mode 100644 index 00000000..29399557 --- /dev/null +++ b/web/src/components/admin/menus/LoggingPage.tsx @@ -0,0 +1,44 @@ +import React, { useContext, useEffect, useState } from "react"; +import { LocaleContext } from "../../../providers/LocaleProvider"; +import toBase64Utf8 from "../../../utilities/base64"; + +const ADMIN_TOKEN = "adminToken"; + +const LoggingPage = () => { + const { locale } = useContext(LocaleContext) + const [response, setResponse] = useState() + + const getLogs = () => { + fetch(`/api/admin/logging`, { + method: "GET", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN))}`, + }, + }) + .then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN) + return; + } + + return result.text(); + }) + .then((result) => { + const reversed: string[] = result?.split('\n').reverse() ?? [""] + setResponse(() => reversed.join('\n').toString()) + }); + }; + + useEffect(() => getLogs(), []) + + return ( +
+

{locale.admin_page_logging.title}

+ +
+ {response} +
+
+ ); +} +export default LoggingPage; diff --git a/web/src/components/admin/menus/ProfilesPage.tsx b/web/src/components/admin/menus/ProfilesPage.tsx new file mode 100644 index 00000000..7ea9ba03 --- /dev/null +++ b/web/src/components/admin/menus/ProfilesPage.tsx @@ -0,0 +1,198 @@ +import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/16/solid"; +import React, { useContext, useEffect, useState } from "react"; +import Button from "../../shared/Button"; +import ModalTextInput from "../../shared/ModalTextInput"; +import ModalMessageBox from "../../shared/ModalMessageBox"; +import { LocaleContext } from "../../../providers/LocaleProvider"; +import { getIcon } from "../../shared/Icons"; +import toBase64Utf8 from "../../../utilities/base64"; + +const ADMIN_TOKEN = "adminToken"; + +interface Profile { + streamKey: string; + token: string; + isPublic: boolean; + motd: string; +} + +const ProfilesPage = () => { + const { locale } = useContext(LocaleContext); + const [response, setResponse] = useState(); + const [isAddProfileModalOpen, setIsAddProfileModalOpen] = useState(false); + const [isRemoveProfileModalOpen, setIsRemoveProfileModalOpen] = useState(""); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + refreshProfiles(); + }, []); + + const copyTokenToClipboard = (token: string) => navigator.clipboard.writeText(token) + + const refreshProfiles = () => { + fetch(`/api/admin/profiles`, { + method: "GET", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN))}`, + }, + }) + .then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN); + return; + } + + return result.json(); + }) + .then((result) => { + setResponse(() => result); + }); + }; + const resetProfileToken = (streamKey: string) => { + fetch(`/api/admin/profiles/reset-token`, { + method: "POST", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN))}`, + }, + body: JSON.stringify({ streamKey: streamKey }), + }).then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN); + return; + } + + refreshProfiles(); + }); + }; + const addProfile = (streamKey: string) => { + fetch(`/api/admin/profiles/add-profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN))}`, + }, + body: JSON.stringify({ streamKey: streamKey }), + }).then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN); + return; + } + + if (result.status === 400) { + result.text().then((resultText) => setErrorMessage(() => resultText)); + + return; + } + + setIsAddProfileModalOpen(() => false); + refreshProfiles(); + }); + }; + const removeProfile = (streamKey: string) => { + fetch(`/api/admin/profiles/remove-profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN))}`, + }, + body: JSON.stringify({ streamKey: streamKey }), + }).then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN); + return; + } + + if (result.status === 400) { + result.text().then((resultText) => setErrorMessage(() => resultText)); + + return; + } + + setIsRemoveProfileModalOpen(() => ""); + refreshProfiles(); + }); + }; + + + return ( +
+

{locale.admin_page_profiles.title}

+ +
+ addProfile(result.toString())} + onDeny={() => setIsAddProfileModalOpen(false)} + /> + + removeProfile(isRemoveProfileModalOpen)} + onDeny={() => setIsRemoveProfileModalOpen("")} + /> + + + + + + + + + + + + + {response?.map((profile, index) => { + return ( + + + + + + + + ); + })} + +
{locale.admin_page_profiles.table_header_stream_key}{locale.admin_page_profiles.table_header_is_public}{locale.admin_page_profiles.table_header_motd}{locale.admin_page_profiles.table_header_token}
+ {profile.streamKey} + + {profile.isPublic + ? locale.admin_page_profiles.yes + : locale.admin_page_profiles.no} + {profile.motd} +
copyTokenToClipboard(profile.token)} > + {getIcon("Copy")} + {profile.token} +
+ + resetProfileToken(profile.streamKey)} + /> +
+ setIsRemoveProfileModalOpen(() => profile.streamKey)} + /> +
+
+ +
+ ); +}; +export default ProfilesPage; diff --git a/web/src/components/admin/menus/StatusPage.tsx b/web/src/components/admin/menus/StatusPage.tsx new file mode 100644 index 00000000..d4fea953 --- /dev/null +++ b/web/src/components/admin/menus/StatusPage.tsx @@ -0,0 +1,118 @@ +import React, { useContext, useEffect, useState } from "react"; +import { LocaleContext } from "../../../providers/LocaleProvider"; +import toBase64Utf8 from "../../../utilities/base64"; + +const ADMIN_TOKEN = "adminToken"; + +const StatusPage = () => { + const { locale } = useContext(LocaleContext) + const [response, setResponse] = useState() + + const refreshStatus = () => { + fetch(`/api/admin/status`, { + method: "GET", + headers: { + Authorization: `Bearer ${toBase64Utf8(localStorage.getItem(ADMIN_TOKEN) ?? "")}`, + }, + }) + .then((result) => { + if (result.status > 400 && result.status < 500) { + localStorage.removeItem(ADMIN_TOKEN) + return; + } + + return result.json(); + }) + .then((result) => { + setResponse(() => result) + }); + }; + + useEffect(() => { + refreshStatus() + }, []) + + return ( +
+

{locale.admin_page_status_page.title}

+ +
+ + + + + + + + + + + + + {response?.sort().map((status, index) => { + const totalVideoPackets = status.videoTracks.reduce( + (sum, track) => sum + track.packetsReceived, + 0 + ); + const totalAudioPackets = status.audioTracks.reduce( + (sum, track) => sum + track.packetsReceived, + 0 + ); + const totalPackets = totalVideoPackets + totalAudioPackets; + + return ( + + + + + + + + + ); + })} + +
{locale.admin_page_status_page.table_header_stream_key}{locale.admin_page_status_page.table_header_is_public}{locale.admin_page_status_page.table_header_video_tracks}{locale.admin_page_status_page.table_header_audio_tracks}{locale.admin_page_status_page.table_header_sessions}{locale.admin_page_status_page.table_header_total_packets}
{status.streamKey}{status.isPublic ? locale.admin_page_status_page.yes : locale.admin_page_status_page.no}{status.videoTracks.length}{status.audioTracks.length}{status.sessions.length}{totalPackets}
+
+
+ ); +} +export default StatusPage; + +interface WhepSession { + id: string; + + audioLayerCurrent: string; + audioTimestamp: string; + audioPacketsWritten: number; + audioSequenceNumber: number; + + videoLayerCurrent: string; + videoTimestamp: string; + videoPacketsWritten: number; + videoSequenceNumber: number; + + sequenceNumber: number; + timestamp: number; +} + +interface StatusResult { + streamKey: string; + isPublic: boolean; + + videoTracks: VideoTrack[]; + audioTracks: AudioTrack[]; + + sessions: WhepSession[]; +} + +interface VideoTrack { + rid: string; + packetsReceived: number; + lastKeyframe: string; +} + +interface AudioTrack { + rid: string; + packetsReceived: number; +} diff --git a/web/src/components/broadcast/Broadcast.tsx b/web/src/components/broadcast/Broadcast.tsx index a3486a5a..da303d12 100644 --- a/web/src/components/broadcast/Broadcast.tsx +++ b/web/src/components/broadcast/Broadcast.tsx @@ -1,9 +1,14 @@ -import React, {useContext, useEffect, useRef, useState} from 'react' -import {useLocation} from 'react-router-dom' -import {useNavigate} from 'react-router-dom' +import React, { useContext, useEffect, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import PlayerHeader from '../playerHeader/PlayerHeader'; -import {StatusContext} from "../../providers/StatusProvider"; -import {UsersIcon} from "@heroicons/react/20/solid"; +import { parseLinkHeader } from '@web3-storage/parse-link-header'; +import Button from '../shared/Button'; +import { ErrorMessageEnum, getMediaErrorMessage } from './errorMessage'; +import ProfileSettings from './ProfileSettings'; +import Player from '../player/Player'; +import { LocaleContext } from '../../providers/LocaleProvider'; +import toBase64Utf8 from '../../utilities/base64'; const mediaOptions = { audio: true, @@ -13,70 +18,51 @@ const mediaOptions = { }, } -enum ErrorMessageEnum { - NoMediaDevices, - NotAllowedError, - NotFoundError -} - -function getMediaErrorMessage(value: ErrorMessageEnum): string { - switch (value) { - case ErrorMessageEnum.NoMediaDevices: - return `MediaDevices API was not found. Publishing in Broadcast Box requires HTTPS 👮`; - case ErrorMessageEnum.NotFoundError: - return `Seems like you don't have camera 😭 Or you just blocked access to it...\nCheck camera settings, browser permissions and system permissions.`; - case ErrorMessageEnum.NotAllowedError: - return `You can't publish stream using your camera, because you have blocked access to it 😞`; - default: - return "Could not access your media device"; - } -} - function BrowserBroadcaster() { const location = useLocation() + const { locale } = useContext(LocaleContext) const navigate = useNavigate(); - const streamKey = location.pathname.split('/').pop() - const { streamStatus } = useContext(StatusContext); + const streamKey = decodeURIComponent(location.pathname.split('/').pop() ?? "") const [mediaAccessError, setMediaAccessError] = useState(null) const [publishSuccess, setPublishSuccess] = useState(false) const [useDisplayMedia, setUseDisplayMedia] = useState<"Screen" | "Webcam" | "None">("None"); const [peerConnectionDisconnected, setPeerConnectionDisconnected] = useState(false) - const [currentViewersCount, setCurrentViewersCount] = useState(0) const [hasPacketLoss, setHasPacketLoss] = useState(false) const [hasSignal, setHasSignal] = useState(false); - const [connectFailed, setConnectFailed] = useState(false) + const [connectFailed, setConnectFailed] = useState(false); + const [profileStateIsActive, setProfileStateIsActive] = useState(false) + const [profileStreamKey, setProfileStreamKey] = useState("") const peerConnectionRef = useRef(null); const videoRef = useRef(null) const hasSignalRef = useRef(false); const badSignalCountRef = useRef(10); - const apiPath = import.meta.env.VITE_API_PATH; + const endStream = () => navigate('/') - const endStream = () => { - navigate('/') + interface ICEComponentServer { + urls: string; + username?: string; + credential?: string } - - useEffect(() => { - peerConnectionRef.current = new RTCPeerConnection(); - - return () => peerConnectionRef.current?.close() - }, []) useEffect(() => { - if(!streamKey || !streamStatus){ - return; - } - - const sessions = streamStatus.filter((session) => session.streamKey === streamKey); + // Fetch ICE-Servers + fetch(`/api/ice-servers`, { + method: 'GET', + }).then(r => r.json()) + .then((result: ICEComponentServer[]) => { + peerConnectionRef.current = new RTCPeerConnection({ + iceServers: result.map(r => ({ + urls: r.urls, + username: r.username, + credential: r.credential, + })) + }); + }) - if(sessions.length !== 0){ - setCurrentViewersCount(() => - sessions.length !== 0 - ? sessions[0].whepSessions.length - : 0) - } - }, [streamStatus]); + return () => peerConnectionRef.current?.close() + }, []) useEffect(() => { if (useDisplayMedia === "None" || !peerConnectionRef.current) { @@ -91,8 +77,7 @@ function BrowserBroadcaster() { return } - const isScreenShare = useDisplayMedia === "Screen" - const mediaPromise = isScreenShare ? + const mediaPromise = useDisplayMedia == "Screen" ? navigator.mediaDevices.getDisplayMedia(mediaOptions) : navigator.mediaDevices.getUserMedia(mediaOptions) @@ -108,26 +93,27 @@ function BrowserBroadcaster() { stream = mediaStream videoRef.current!.srcObject = mediaStream + const encodingPrefix = "Web" mediaStream .getTracks() .forEach(mediaStreamTrack => { if (mediaStreamTrack.kind === 'audio') { peerConnectionRef.current!.addTransceiver(mediaStreamTrack, { - direction: 'sendonly' + direction: 'sendonly', }) } else { peerConnectionRef.current!.addTransceiver(mediaStreamTrack, { direction: 'sendonly', - sendEncodings: isScreenShare ? [] : [ + sendEncodings: [ { - rid: 'high', + rid: encodingPrefix + 'High', }, { - rid: 'med', + rid: encodingPrefix + 'Mid', scaleResolutionDownBy: 2.0 }, { - rid: 'low', + rid: encodingPrefix + 'Low', scaleResolutionDownBy: 4.0 } ] @@ -153,27 +139,38 @@ function BrowserBroadcaster() { peerConnectionRef.current!.setLocalDescription(offer) .catch((err) => console.error("SetLocalDescription", err)); - fetch(`${apiPath}/whip`, { + fetch(`/api/whip`, { method: 'POST', body: offer.sdp, headers: { - Authorization: `Bearer ${streamKey}`, + Authorization: `Bearer ${toBase64Utf8(streamKey)}`, 'Content-Type': 'application/sdp' } }).then(r => { - setConnectFailed(r.status !== 201) - if (connectFailed) { - throw new DOMException("WHIP endpoint did not return 201"); + + if (r.status !== 201) { + setConnectFailed(() => true) + console.error("WHIP Endpoint did not return 201") + } + const parsedLinkHeader = parseLinkHeader(r.headers.get('Link')) + + if (parsedLinkHeader === null || parsedLinkHeader === undefined) { + throw new DOMException("Missing link header"); } + const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`) + + evtSource.onerror = () => evtSource.close(); + + // Receive current status of the stream + // evtSource.addEventListener("status", (event: MessageEvent) => setCurrentStreamStatus(JSON.parse(event.data))) + return r.text() - }) - .then(answer => { + }).then(answer => { peerConnectionRef.current!.setRemoteDescription({ sdp: answer, type: 'answer' - }) - .catch((err) => console.error("SetRemoveDescription", err)) + }).catch((err) => console.error("SetRemoteDescription", err)) }) }) }, (reason: ErrorMessageEnum) => { @@ -189,6 +186,7 @@ function BrowserBroadcaster() { .forEach((streamTrack: MediaStreamTrack) => streamTrack.stop()) } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoRef, useDisplayMedia, location.pathname]) useEffect(() => { @@ -201,20 +199,20 @@ function BrowserBroadcaster() { sender.getStats() .then(stats => { stats.forEach(report => { - if (report.type === "outbound-rtp") { - senderHasPacketLoss = report.totalPacketSendDelay > 10; - } - if (report.type === "candidate-pair") { - const signalIsValid = report.availableIncomingBitrate !== undefined; - badSignalCountRef.current = signalIsValid ? 0 : badSignalCountRef.current + 1; - - if (badSignalCountRef.current > 2) { - setHasSignal(() => false); - } else if (badSignalCountRef.current === 0 && !hasSignalRef.current) { - setHasSignal(() => true); - } + if (report.type === "outbound-rtp") { + senderHasPacketLoss = report.totalPacketSendDelay > 10; + } + if (report.type === "candidate-pair") { + const signalIsValid = report.availableIncomingBitrate !== undefined; + badSignalCountRef.current = signalIsValid ? 0 : badSignalCountRef.current + 1; + + if (badSignalCountRef.current > 2) { + setHasSignal(() => false); + } else if (badSignalCountRef.current === 0 && !hasSignalRef.current) { + setHasSignal(() => true); } } + } ) }) } @@ -231,52 +229,60 @@ function BrowserBroadcaster() { }, [hasSignal]); return ( -
- {mediaAccessError != null && {getMediaErrorMessage(mediaAccessError)} } - {peerConnectionDisconnected && WebRTC has disconnected or failed to connect at all 😭 } - {connectFailed && Failed to start Broadcast Box session 👮 } - {hasPacketLoss && WebRTC is experiencing packet loss} - {publishSuccess && Live: Currently streaming to {window.location.href.replace('publish/', '')} } - -
- ) -} - -export default Player +import React, { useEffect, useRef, useState } from 'react' +import PlayPauseComponent from "./components/PlayPauseComponent"; +import VideoLayerSelectorComponent from "./components/VideoLayerSelectorComponent"; +import AudioLayerSelectorComponent from "./components/AudioLayerSelectorComponent"; +import CurrentViewersComponent from "./components/CurrentViewersComponent"; +import { StreamStatus } from '../../providers/StatusProvider'; +import { CurrentLayersMessage, PeerConnectionSetup, SetupPeerConnectionProps } from './functions/peerconnection'; +import { ArrowsPointingOutIcon, Square2StackIcon } from '@heroicons/react/20/solid'; +import VolumeComponent from './components/VolumeComponent'; +import { StatusMessageComponent } from './components/StatusMessageComponent'; +import { StreamMOTD } from './components/StreamMOTD'; + +interface PlayerProps { + streamKey: string; + cinemaMode: boolean; + onCloseStream?: () => void; +} + +const Player = (props: PlayerProps) => { + const { cinemaMode } = props; + const streamKey = decodeURIComponent(props.streamKey).replace(' ', '_') + + const [currentStreamStatus, setCurrentStreamStatus] = useState({ + streamKey: streamKey, + motd: "", + viewers: 0, + isOnline: false, + }) + + const [currentLayersStatus, setCurrentLayersStatus] = useState() + const [audioLayers, setAudioLayers] = useState([]); + const [videoLayers, setVideoLayers] = useState([]); + const [streamState, setStreamState] = useState<"Loading" | "Playing" | "Offline" | "Error">("Loading"); + const [videoOverlayVisible, setVideoOverlayVisible] = useState(false) + + const [resetCounter, setResetCounter] = useState(0) + + const clickDelay = 250; + const videoRef = useRef(null); + const layerEndpointRef = useRef(''); + const videoOverlayVisibleTimeoutRef = useRef(undefined); + const lastClickTimeRef = useRef(0); + const clickTimeoutRef = useRef(undefined); + const streamVideoPlayerId = streamKey + "_videoPlayer"; + + const peerConnectionConfig: SetupPeerConnectionProps = { + streamKey: streamKey, + videoRef: videoRef, + layerEndpointRef: layerEndpointRef, + onStateChange: (state) => console.log("PeerConnection.onStateChange", state), + onStreamRestart: () => console.log("PeerConnection.onStreamRestart: Missing setup"), + onAudioLayerChange: (layers) => setAudioLayers(layers), + onVideoLayerChange: (layers) => setVideoLayers(layers), + onLayerStatus: (status) => setCurrentLayersStatus(status), + onStreamStatus: (status) => { + if (!status.isOnline) { + setStreamState("Offline") + } + setCurrentStreamStatus(() => status) + }, + onError: () => setStreamState("Error"), + } + + const resetTimer = (isVisible: boolean) => { + setVideoOverlayVisible(() => isVisible); + + if (videoOverlayVisibleTimeoutRef) { + clearTimeout(videoOverlayVisibleTimeoutRef.current) + } + + videoOverlayVisibleTimeoutRef.current = setTimeout(() => { + setVideoOverlayVisible(() => false) + }, 2500) + } + + const handleVideoPlayerClick = () => { + lastClickTimeRef.current = Date.now(); + + clickTimeoutRef.current = setTimeout(() => { + const timeSinceLastClick = Date.now() - lastClickTimeRef.current; + if (timeSinceLastClick >= clickDelay && (timeSinceLastClick - clickDelay) < 5000) { + videoRef.current?.paused + ? videoRef.current?.play() + : videoRef.current?.pause(); + } + }, clickDelay); + }; + + const handleVideoPlayerDoubleClick = () => { + clearTimeout(clickTimeoutRef.current); + lastClickTimeRef.current = 0; + videoRef.current?.requestFullscreen() + .catch(err => console.error("VideoPlayer_RequestFullscreen", err)); + }; + + useEffect(() => { + const handleOverlayTimer = (isVisible: boolean) => resetTimer(isVisible) + + const player = document.getElementById(streamVideoPlayerId) + player?.addEventListener('mouseup', () => handleOverlayTimer(true)) + player?.addEventListener('mousemove', () => handleOverlayTimer(true)) + player?.addEventListener('mouseenter', () => handleOverlayTimer(true)) + player?.addEventListener('mouseleave', () => handleOverlayTimer(false)) + + peerConnectionConfig.onStreamRestart = () => { + // setCurrentLayersStatus(undefined) + setResetCounter((prev) => prev + 1) + + PeerConnectionSetup(peerConnectionConfig) + .then((peerConnection) => { + window.addEventListener("beforeunload", () => peerConnection.close()) + }) + .catch((err) => console.log("PeerConnectionConfig.Error", err)) + } + + PeerConnectionSetup(peerConnectionConfig) + .then((peerConnection) => { + window.addEventListener("beforeunload", () => peerConnection.close()) + }) + .catch((err) => console.log("PeerConnectionConfig.Error", err)) + + return () => { + player?.removeEventListener('mouseup', () => handleOverlayTimer) + player?.removeEventListener('mouseenter', () => handleOverlayTimer) + player?.removeEventListener('mouseleave', () => handleOverlayTimer) + player?.removeEventListener('mousemove', () => handleOverlayTimer) + + clearTimeout(videoOverlayVisibleTimeoutRef.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ +
+ +
+ + {/*Buttons */} + {videoRef.current !== null && ( +
+
e.stopPropagation()} + className="bg-blue-950 w-full flex flex-row gap-2 h-1/14 p-1 max-h-8 min-h-8 rounded-md"> + + + + videoRef.current!.volume = newValue / 100} + onStateChanged={(newState) => videoRef.current!.muted = newState} + /> + +
+ + + + {audioLayers.length > 1 && ( + + )} + videoRef.current?.requestPictureInPicture()} /> + videoRef.current?.requestFullscreen()} /> +
+
) + } + + {/* Status messages */} + + + {!!props.onCloseStream && ( + + )} + +
+ +
+ + {/* Stream MOTD*/} + + +
+ ) +} + +export default Player + diff --git a/web/src/components/player/PlayerPage.tsx b/web/src/components/player/PlayerPage.tsx index 264b31a6..078468ee 100644 --- a/web/src/components/player/PlayerPage.tsx +++ b/web/src/components/player/PlayerPage.tsx @@ -1,13 +1,17 @@ -import React, {useContext, useState} from "react"; +import React, { useContext, useState } from "react"; import Player from "./Player"; -import {useNavigate} from "react-router-dom"; -import {CinemaModeContext} from "../../providers/CinemaModeProvider"; +import { useNavigate } from "react-router-dom"; +import { CinemaModeContext } from "../../providers/CinemaModeProvider"; import ModalTextInput from "../shared/ModalTextInput"; +import Button from "../shared/Button"; +import AvailableStreams from "../selection/AvailableStreams"; +import { LocaleContext } from "../../providers/LocaleProvider"; const PlayerPage = () => { const navigate = useNavigate(); - const {cinemaMode, toggleCinemaMode} = useContext(CinemaModeContext); - const [streamKeys, setStreamKeys] = useState([window.location.pathname.substring(1)]); + const { locale } = useContext(LocaleContext); + const { cinemaMode, toggleCinemaMode } = useContext(CinemaModeContext); + const [streamKeys, setStreamKeys] = useState([ window.location.pathname.substring(1) ]); const [isModalOpen, setIsModelOpen] = useState(false); const addStream = (streamKey: string) => { @@ -22,50 +26,59 @@ const PlayerPage = () => {
{isModalOpen && ( - title="Add stream" - message={"Insert stream key to add to multi stream"} + title={locale.player_page.modal_add_stream_title} + message={locale.player_page.modal_add_stream_message} + placeholder={locale.player_page.modal_add_stream_placeholder} isOpen={isModalOpen} - canCloseOnBackgroundClick={false} + canCloseOnBackgroundClick={true} onClose={() => setIsModelOpen(false)} onAccept={(result: string) => addStream(result)} - /> + > + addStream(streamKey)} + /> + )} -
-
- {streamKeys.map((streamKey) => +
+
+ {streamKeys.map((streamKey) => ( navigate('/') - : () => setStreamKeys((prev) => prev.filter((key) => key !== streamKey)) + ? () => navigate("/") + : () => + setStreamKeys((prev) => + prev.filter((key) => key !== streamKey), + ) } /> - )} + ))}
- {/*Implement footer menu*/} -
- + iconRight="CodeBracketSquare" + /> {/*Show modal to add stream keys with*/} - +
- ) + ); }; -export default PlayerPage; \ No newline at end of file +export default PlayerPage; diff --git a/web/src/components/player/components/QualitySelectorComponent.tsx b/web/src/components/player/components/AudioLayerSelectorComponent.tsx similarity index 57% rename from web/src/components/player/components/QualitySelectorComponent.tsx rename to web/src/components/player/components/AudioLayerSelectorComponent.tsx index 66493913..50daee80 100644 --- a/web/src/components/player/components/QualitySelectorComponent.tsx +++ b/web/src/components/player/components/AudioLayerSelectorComponent.tsx @@ -1,20 +1,22 @@ -import React, {ChangeEvent, useState} from "react"; -import {ChartBarIcon} from "@heroicons/react/16/solid"; +import React, { ChangeEvent, useState } from "react"; +import { MusicalNoteIcon } from "@heroicons/react/20/solid"; interface QualityComponentProps { layers: string[]; layerEndpoint: string; hasPacketLoss: boolean; + currentLayer: string; } -const QualitySelectorComponent = (props: QualityComponentProps) => { +const AudioLayerSelectorComponent = (props: QualityComponentProps) => { + const audioMediaId = "2" const [isOpen, setIsOpen] = useState(false); - const [currentLayer, setCurrentLayer] = useState(''); + const [currentLayer, setCurrentLayer] = useState(props.currentLayer); const onLayerChange = (event: ChangeEvent) => { fetch(props.layerEndpoint, { method: 'POST', - body: JSON.stringify({mediaId: '1', encodingId: event.target.value}), + body: JSON.stringify({ mediaId: audioMediaId, encodingId: event.target.value }), headers: { 'Content-Type': 'application/json' } @@ -27,22 +29,22 @@ const QualitySelectorComponent = (props: QualityComponentProps) => { currentLayer, ...props.layers.filter(layer => layer !== currentLayer) ].map(layer => ) - if (layerList[0].props.value === '') { - layerList[0] = + if (currentLayer === '' || layerList[0].props.value === '') { + layerList[0] = } return (
- setIsOpen((prev) => props.layers.length <= 1 ? false : !prev)}/> + setIsOpen((prev) => props.layers.length <= 1 ? false : !prev)} /> {isOpen && ( - - + { + layerList + } + )}
) } -export default QualitySelectorComponent \ No newline at end of file +export default AudioLayerSelectorComponent diff --git a/web/src/components/player/components/CurrentViewersComponent.tsx b/web/src/components/player/components/CurrentViewersComponent.tsx index 4d1a82cc..10361e68 100644 --- a/web/src/components/player/components/CurrentViewersComponent.tsx +++ b/web/src/components/player/components/CurrentViewersComponent.tsx @@ -1,41 +1,19 @@ -import React, {useContext, useEffect, useState} from "react"; -import {UsersIcon} from "@heroicons/react/20/solid"; -import {StatusContext} from "../../../providers/StatusProvider"; +import React from "react"; +import { UsersIcon } from "@heroicons/react/20/solid"; interface CurrentViewersComponentProps { - streamKey: string; + currentViewersCount: number; } const CurrentViewersComponent = (props: CurrentViewersComponentProps) => { - const { streamKey } = props; - const { streamStatus, refreshStatus } = useContext(StatusContext); - const [currentViewersCount, setCurrentViewersCount] = useState(0) - - useEffect(() => { - refreshStatus() - }, []); - - useEffect(() => { - if(!streamKey || !streamStatus){ - return; - } - - const sessions = streamStatus.filter((session) => session.streamKey === streamKey); - - if(sessions.length !== 0){ - setCurrentViewersCount(() => - sessions.length !== 0 - ? sessions[0].whepSessions.length - : 0) - } - }, [streamStatus]); + const { currentViewersCount } = props; return (
- + {currentViewersCount}
) } -export default CurrentViewersComponent \ No newline at end of file +export default CurrentViewersComponent diff --git a/web/src/components/player/components/PlayPauseComponent.tsx b/web/src/components/player/components/PlayPauseComponent.tsx index f2f1cd27..b046253d 100644 --- a/web/src/components/player/components/PlayPauseComponent.tsx +++ b/web/src/components/player/components/PlayPauseComponent.tsx @@ -1,4 +1,6 @@ -import React, {useEffect, useState} from "react"; +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable no-unused-vars */ +import React, {useEffect, useState} from "react"; import {PauseIcon, PlayIcon} from "@heroicons/react/16/solid"; interface PlayPauseComponentProps { @@ -8,10 +10,6 @@ interface PlayPauseComponentProps { const PlayPauseComponent = (props: PlayPauseComponentProps) => { const [isPaused, setIsPaused] = useState(true); - if (props.videoRef.current === null) { - return <>; - } - useEffect(() => { if (props.videoRef.current === null) { return; @@ -32,7 +30,7 @@ const PlayPauseComponent = (props: PlayPauseComponentProps) => { props.videoRef.current.removeEventListener("pause", pauseHandler); } } - }, []); + }, [props.videoRef.current]); useEffect(() => { if(isPaused){ @@ -43,6 +41,10 @@ const PlayPauseComponent = (props: PlayPauseComponentProps) => { } }, [isPaused]); + if (props.videoRef.current === null) { + return <>; + } + if (isPaused) { return props.videoRef.current?.play()}/> } @@ -51,4 +53,4 @@ const PlayPauseComponent = (props: PlayPauseComponentProps) => { } } -export default PlayPauseComponent \ No newline at end of file +export default PlayPauseComponent diff --git a/web/src/components/player/components/StatusMessageComponent.tsx b/web/src/components/player/components/StatusMessageComponent.tsx new file mode 100644 index 00000000..95edf853 --- /dev/null +++ b/web/src/components/player/components/StatusMessageComponent.tsx @@ -0,0 +1,44 @@ +import React, { useContext } from "react"; +import { VideoCameraIcon, VideoCameraSlashIcon } from "@heroicons/react/16/solid"; +import { LocaleContext } from "../../../providers/LocaleProvider"; + +interface StatusMessageComponentProps{ + streamKey: string; + state: "Loading" | "Playing" | "Offline" | "Error" +} + +export const StatusMessageComponent = (props: StatusMessageComponentProps) => { + const { locale } = useContext(LocaleContext) + const { streamKey, state } = props + + if(state === "Playing"){ + return + } + + return
+ {state === "Error" && ( +
+
+ + {streamKey} {locale.player.message_error} +
+
+ )} + {state === "Offline" && ( +
+
+ + {streamKey} {locale.player.message_is_not_online} +
+
+ )} + {state === "Loading" && ( +
+
+ + {streamKey} {locale.player.message_loading_video} +
+
+ )} +
+} diff --git a/web/src/components/player/components/StreamMOTD.tsx b/web/src/components/player/components/StreamMOTD.tsx new file mode 100644 index 00000000..bf0691ad --- /dev/null +++ b/web/src/components/player/components/StreamMOTD.tsx @@ -0,0 +1,26 @@ +import React, { useContext } from "react"; +import { LocaleContext } from "../../../providers/LocaleProvider"; + +interface StreamMOTDProps { + isOnline: boolean; + motd: string; +} +export const StreamMOTD = (props: StreamMOTDProps) =>{ + const {isOnline, motd} = props; + const { locale } = useContext(LocaleContext) + + return ( +
+
+
+ {motd} +
+ +
+
+ {locale.player.stream_status_offline} +
+
+
+
) +} diff --git a/web/src/components/player/components/VideoLayerSelectorComponent.tsx b/web/src/components/player/components/VideoLayerSelectorComponent.tsx new file mode 100644 index 00000000..641118ed --- /dev/null +++ b/web/src/components/player/components/VideoLayerSelectorComponent.tsx @@ -0,0 +1,78 @@ +import React, { ChangeEvent, useEffect, useState } from "react"; +import { ChartBarIcon } from "@heroicons/react/16/solid"; + +interface QualityComponentProps { + layers: string[]; + layerEndpoint: string; + hasPacketLoss: boolean; + currentLayer: string; +} + +const VideoLayerSelectorComponent = (props: QualityComponentProps) => { + const videoMediaId = "1" + const [isOpen, setIsOpen] = useState(false); + const [currentLayer, setCurrentLayer] = useState(props.currentLayer); + + const onLayerChange = (event: ChangeEvent) => { + fetch(props.layerEndpoint, { + method: 'POST', + body: JSON.stringify({ mediaId: videoMediaId, encodingId: event.target.value }), + headers: { + 'Content-Type': 'application/json' + } + }).catch((err) => console.error("VideoLayerSelectorComponent.onLayerChange", err)) + setIsOpen(false) + setCurrentLayer(event.target.value) + } + + let layerList = [ + currentLayer, + ...props.layers.filter(layer => layer !== currentLayer) + ].map(layer => ) + + if (currentLayer === '' || layerList[0].props.value === '') { + layerList[0] = + } + + useEffect(() => { + setCurrentLayer(() => props.currentLayer) + }, [props.currentLayer]) + + return ( +
+ setIsOpen((prev) => props.layers.length <= 1 ? false : !prev)} /> + + {isOpen && ( + + )} +
+ ) +} + +export default VideoLayerSelectorComponent diff --git a/web/src/components/player/components/VolumeComponent.tsx b/web/src/components/player/components/VolumeComponent.tsx index f0383637..b6d7d0a6 100644 --- a/web/src/components/player/components/VolumeComponent.tsx +++ b/web/src/components/player/components/VolumeComponent.tsx @@ -1,8 +1,11 @@ -import React, {useEffect, useRef, useState} from "react"; -import {SpeakerWaveIcon, SpeakerXMarkIcon} from "@heroicons/react/16/solid"; +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable no-unused-vars */ +import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/16/solid"; +import React, { useEffect, useRef, useState } from "react"; interface VolumeComponentProps { isMuted: boolean; + isDisabled?: boolean; onStateChanged: (isMuted: boolean) => void; onVolumeChanged: (value: number) => void; } @@ -10,21 +13,24 @@ interface VolumeComponentProps { const VolumeComponent = (props: VolumeComponentProps) => { const [isMuted, setIsMuted] = useState(props.isMuted); const [showSlider, setShowSlider] = useState(false); - const volumeRef = useRef(20); - + useEffect(() => { props.onStateChanged(isMuted); }, [isMuted]); - + const onVolumeChange = (newValue: number) => { - if(isMuted && newValue !== 0){ + if (isMuted && newValue !== 0) { setIsMuted((_) => false) } - if(!isMuted && newValue === 0){ + if (!isMuted && newValue === 0) { setIsMuted((_) => true) } - - props.onVolumeChanged(newValue / 100); + + props.onVolumeChanged(newValue); + } + + if (props.isDisabled) { + return () } return
{ className="flex justify-start max-w-42 gap-2 items-center" > {isMuted && ( - setIsMuted((prev) => !prev)}/> + setIsMuted((prev) => !prev)} /> )} {!isMuted && ( - setIsMuted((prev) => !prev)}/> + setIsMuted((prev) => !prev)} /> )} + + + +
+} + +interface VolumeSliderProps { + isVisible: boolean; + onVolumeChange: (value: number) => void +} +const VolumeSlider = (props: VolumeSliderProps) => { + const inputRef = useRef(null); + const volumeRef = useRef(50); + + // Forces UI rendering + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setCurrentVolume] = useState(volumeRef.current) + + const setVolume = (value: number) => { + props.onVolumeChange(value) + setCurrentVolume(() => value) + volumeRef.current = value + } + + useEffect(() => { + const wheelHandler = (event: WheelEvent) => { + event.preventDefault() + + let newValue = volumeRef.current + (event.deltaY < 0 ? 1 : -1); + + if (newValue > 100) { + newValue = 100 + } + if (newValue < 0) { + newValue = 0 + } + + setVolume(newValue) + } + + inputRef.current?.addEventListener("wheel", wheelHandler, { passive: false }) + + return () => { + inputRef.current?.removeEventListener("wheel", wheelHandler) + } + }, []) + + return
onVolumeChange(parseInt(event.target.value))} - className={ - ` - ${!showSlider && ` - invisible - `} - w-18 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700`}/> + value={volumeRef.current} + onChange={(event) => setVolume(parseInt(event.target.value))} + className={`h-2 w-18 rounded-lg appearance-none cursor-pointer dark:bg-gray-700`} + /> +
} + export default VolumeComponent diff --git a/web/src/components/player/functions/peerconnection.tsx b/web/src/components/player/functions/peerconnection.tsx new file mode 100644 index 00000000..3c4a08ae --- /dev/null +++ b/web/src/components/player/functions/peerconnection.tsx @@ -0,0 +1,200 @@ +/* eslint-disable no-unused-vars */ +import { parseLinkHeader } from "@web3-storage/parse-link-header"; +import { StreamStatus } from "../../../providers/StatusProvider"; +import toBase64Utf8 from "../../../utilities/base64"; +import { RefObject } from "react"; + +export interface CurrentLayersMessage { + id: string, + audioLayerCurrent: string + audioTimestamp: number + audioPacketsWritten: number + audioSequenceNumber: number + + videoLayerCurrent: string + videoTimestamp: number + videoPacketsWritten: number + videoSequenceNumber: number +} + +enum SetupPeerConnectionError { + INVALID_WHEP_RESPONSE +} +enum SetupPeerConnectionStateChange { + ONLINE, + OFFLINE +} +export interface SetupPeerConnectionProps { + streamKey: string, + videoRef: RefObject, + layerEndpointRef: RefObject, + + onError: (error: SetupPeerConnectionError) => void, + onStreamStatus: (status: StreamStatus) => void, + onLayerStatus: (layers: CurrentLayersMessage) => void, + onAudioLayerChange: (layers: []) => void, + onVideoLayerChange: (layers: []) => void, + onStateChange: (state: SetupPeerConnectionStateChange) => void, + onStreamRestart: () => void, +} + +const stopVideoTrack = (videoElement: HTMLVideoElement | null) => { + const currentStream = videoElement?.srcObject; + if (currentStream instanceof MediaStream) { + currentStream.getTracks().forEach(track => track.stop()); + } +} +const clearVideoElement = (videoElement: HTMLVideoElement | null) => { + if(videoElement){ + videoElement.muted = true + videoElement.srcObject = null + } +} + +export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Promise { + const { + streamKey, + videoRef, + layerEndpointRef, + onStreamRestart, + onStreamStatus, + onLayerStatus, + onAudioLayerChange, + onVideoLayerChange, + onStateChange, + onError } = props + + if (videoRef.current === null){ + throw new Error("PeerConnection.VideoRef is null") + } + + stopVideoTrack(videoRef.current) + clearVideoElement(videoRef.current) + + // Create peerconnection + const peerConnection = await createPeerConnection() + + // Config + peerConnection.addTransceiver('audio', { direction: 'recvonly' }) + peerConnection.addTransceiver('video', { direction: 'recvonly' }) + + // Setup events + const remoteStream = new MediaStream(); + peerConnection.ontrack = (event: RTCTrackEvent) => { + remoteStream.addTrack(event.track); + if (videoRef.current) { + videoRef.current!.srcObject = remoteStream; + } else { + console.log("PeerConnection.onTrack", "Could not find VideoRef") + } + + event.track.onended = () => remoteStream.removeTrack(event.track) + } + + // Begin negotiation + const offer = await peerConnection.createOffer({ iceRestart: true }) + offer["sdp"] = offer["sdp"]!.replace("useinbandfec=1", "useinbandfec=1;stereo=1") + + await peerConnection + .setLocalDescription(offer) + .catch((err) => console.error("PeerConnection.SetLocalDescription", err)); + + await waitForIceGatheringComplete(peerConnection) + + const whepResponse = await fetch(`/api/whep`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sdp' + }, + body: toBase64Utf8(JSON.stringify({ + streamKey: streamKey, + offer: offer.sdp + })), + }) + + if (!whepResponse.ok) { + console.log("PeerConnection.WhepResponse.Error", SetupPeerConnectionError.INVALID_WHEP_RESPONSE) + onError(SetupPeerConnectionError.INVALID_WHEP_RESPONSE) + } + + const parsedLinkHeader = parseLinkHeader(whepResponse.headers.get('Link')) + + if (parsedLinkHeader === null || parsedLinkHeader === undefined) { + throw new DOMException("Missing link header"); + } + + layerEndpointRef.current = `${parsedLinkHeader['urn:ietf:params:whep:ext:core:layer'].url}` + const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`) + + evtSource.onerror = (ev: Event) => { + console.error("PeerConnection.EventSource", ev) + evtSource.close(); + onStateChange(SetupPeerConnectionStateChange.OFFLINE) + } + + // Receive current status of the whep stream + evtSource.addEventListener("streamStart", () => { + console.log("PeerConnection.EventSource", "Reset Stream", streamKey) + + evtSource.close() + peerConnection.close() + + onStreamRestart() + }) + + // Receive current status of the whep stream + evtSource.addEventListener("status", (event: MessageEvent) => { + onStreamStatus(JSON.parse(event.data) as StreamStatus) + }) + + // Receive current current layers of this whep stream + evtSource.addEventListener("currentLayers", (event: MessageEvent) => { + onLayerStatus(JSON.parse(event.data) as CurrentLayersMessage) + }) + + // Receive layers + evtSource.addEventListener("layers", event => { + const parsed = JSON.parse(event.data) + onVideoLayerChange(parsed['1']['layers'].map((layer: any) => layer.encodingId)) + onAudioLayerChange(parsed['2']['layers'].map((layer: any) => layer.encodingId)) + }) + + const answer = await whepResponse.text() + await peerConnection.setRemoteDescription({ + sdp: answer, + type: 'answer' + }).catch((err) => console.error("PeerConnection.RemoteDescription", err)) + + return peerConnection; +} + +async function createPeerConnection(): Promise { + return await fetch(`/api/ice-servers`, { + method: 'GET', + }).then(r => r.json()) + .then((result) => { + return new RTCPeerConnection({ + iceServers: result + }); + }).catch(() => { + console.error("Error calling Ice-Servers endpoint. Ignoring STUN/TURN configuration") + return new RTCPeerConnection(); + }) +} + +export function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) { + return new Promise(resolve => { + if (peerConnection.iceGatheringState === 'complete') { + resolve(true); + } else { + const checkState = () => { + if (peerConnection.iceGatheringState === 'complete') { + peerConnection.removeEventListener('icegatheringstatechange', checkState); + resolve(true); + } + }; + peerConnection.addEventListener('icegatheringstatechange', checkState); + } + }); +} + diff --git a/web/src/components/playerHeader/PlayerHeader.tsx b/web/src/components/playerHeader/PlayerHeader.tsx index 19a6cd36..69af0bc9 100644 --- a/web/src/components/playerHeader/PlayerHeader.tsx +++ b/web/src/components/playerHeader/PlayerHeader.tsx @@ -22,4 +22,4 @@ const PlayerHeader = (props: PlayerHeaderProps) => { ) } -export default PlayerHeader; \ No newline at end of file +export default PlayerHeader; diff --git a/web/src/components/rootWrapper/RootWrapper.tsx b/web/src/components/rootWrapper/RootWrapper.tsx index 52c14af1..5231c040 100644 --- a/web/src/components/rootWrapper/RootWrapper.tsx +++ b/web/src/components/rootWrapper/RootWrapper.tsx @@ -1,27 +1,32 @@ import { useContext } from 'react'; import { Link, Outlet } from 'react-router-dom' import React from 'react'; -import {CinemaModeContext} from "../../providers/CinemaModeProvider"; +import { CinemaModeContext } from "../../providers/CinemaModeProvider"; +import { HeaderContext } from '../../providers/HeaderProvider'; +import LocalesModal from '../shared/ModalLocaleSelector'; const RootWrapper = () => { + const { title } = useContext(HeaderContext) const { cinemaMode } = useContext(CinemaModeContext); const navbarEnabled = !cinemaMode; - + return ( -
+
{navbarEnabled && ( -