diff --git a/.DS_Store b/.DS_Store index ab838e9..15cf5de 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 78f83da..1d740fe 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ go.work go.work.sum # Environment files +/videochat/setup-livekit.txt .env .env.development.local .env.test.local diff --git a/README.md b/README.md index 1e88e3f..bfe952f 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,25 @@ http://localhost:9400/ ``` Le API locali sono esposte dall'ambiente Encore locale. Consulta il Service Catalog nel dashboard per endpoint e schema aggiornati. + +## Videochat + +Il servizio `videochat` integra LiveKit per gestire stanze, partecipanti e token di accesso. +Configurazione e API: [videochat/README.md](videochat/README.md). + +### Setup personale + +`GET /profiles/personal-setup` restituisce il setup dell'utente autenticato: +`user_id`, `can_broadcast` (bool) e `room_name` (string). +In assenza di setup, restituisce `can_broadcast: false` e `room_name: ""`. + +Gli amministratori possono impostarlo con `PUT /profiles/personal-setup/:userID`: + +```json +{"can_broadcast": true, "room_name": "stanza-utente"} +``` + +Il nome stanza è obbligatorio quando il broadcasting è abilitato e viene cancellato +quando è disabilitato. Il setup è salvato nel database `profiles` dalla migrazione 11. +Queste API gestiscono i dati del setup; l'applicazione del permesso ai token LiveKit +e la creazione della stanza non sono automatiche. diff --git a/extrahome/data.go b/extrahome/data.go new file mode 100644 index 0000000..56e31cf --- /dev/null +++ b/extrahome/data.go @@ -0,0 +1,143 @@ +package extrahome + +import "time" + +type SiteConfig struct { + ExtraSite ExtraSite + StartAt time.Time + Description string +} + +type ExtraSite struct { + NavItems []NavItem + HomePageData HomePageData + BroadcasterPageData BroadcasterPageData + ContactPageData ContactPageData + Carusel Carusel +} + +type NavItem struct { + Label string + Color string + URL string +} + +type HomePageData struct { + Title string + Lang string + HeroTitle string + Profiles []Profile + Promo Promo + ListOfBroadcasters ListOfBroadcaster + Carusel Carusel + Year int +} + +type BroadcasterPageData struct { + Title string + Lang string + Year int + PromoRight1 Promo + PromoRight2 Promo + Profile BroadcasterProfile +} + +type ContactPageData struct { + Title string + Lang string + HeroTitle string + Email string + Phone string + Address string + Hours string + Message string + Year int +} + +type Carusel struct { + Items []CaruselItem +} + +type CaruselItem struct { + Title string + Description string + ImageURL string + URL string +} + +type Promo struct { + Left PromoItem + Right1 PromoItem + Right2 PromoItem +} + +type PromoItem struct { + Title string + Description string + Content []PromoContent +} + +type PromoContent struct { + Width int + Height int + MobileOnly bool + MobileImageURL string + NewWindow bool + Title string + Description string + ImageURL string + URL string +} + +type ListOfBroadcaster struct { + Title string + Items []ListOfBroadcasterItem +} + +type ListOfBroadcasterItem struct { + Title string + Category string + Description string + ImageURL string + URL string +} + +type Profile struct { + Name string + ProfilePhoto ProfilePhoto + Category string + Description string + URL string +} + +type ProfilePhoto struct { + ImageURL string + Alt string +} + +type ProfileHours struct { + Day string + Hours string +} + +type ProfileAttribute struct { + Label string + Value string +} + +type BroadcasterProfile struct { + ID int64 + Name string + Category string + CategoryColor string + Certified bool + Introduction []string + Photos []ProfilePhoto + Hours []ProfileHours + Availability string + Phone string + PhoneNumber string + Attributes []ProfileAttribute + Languages []string + Services []string +} diff --git a/extrahome/extrahome.go b/extrahome/extrahome.go new file mode 100644 index 0000000..098b0e1 --- /dev/null +++ b/extrahome/extrahome.go @@ -0,0 +1,23 @@ +// Service extrahome provides extrahome APIs. +package extrahome + +import ( + "context" + + "encore.dev/storage/sqldb" +) + +var db = sqldb.NewDatabase("extrahome", sqldb.DatabaseConfig{ + Migrations: "./migrations", +}) + +type StatusResponse struct { + Status string `json:"status"` +} + +// Status reports whether the extrahome service is available. +// +//encore:api public method=GET path=/extrahome/status +func Status(ctx context.Context) (*StatusResponse, error) { + return &StatusResponse{Status: "ok"}, nil +} diff --git a/extrahome/migrations/1_create_siteconfig.up.sql b/extrahome/migrations/1_create_siteconfig.up.sql new file mode 100644 index 0000000..dac3a0b --- /dev/null +++ b/extrahome/migrations/1_create_siteconfig.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE siteconfig ( + id BIGSERIAL PRIMARY KEY, + extrsite JSONB NOT NULL, + start_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + description TEXT NOT NULL DEFAULT '', + CONSTRAINT siteconfig_extrsite_object CHECK (jsonb_typeof(extrsite) = 'object') +); diff --git a/go.mod b/go.mod index 6233a68..b7ed53d 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,86 @@ go 1.26 require ( encore.dev v1.57.5 github.com/jackc/pgx/v5 v5.7.6 - golang.org/x/crypto v0.42.0 + github.com/livekit/protocol v1.49.0 + github.com/livekit/server-sdk-go/v2 v2.18.1 + github.com/twitchtv/twirp v8.1.3+incompatible + golang.org/x/crypto v0.52.0 + google.golang.org/protobuf v1.36.11 ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/go/protovalidate v1.2.0 // indirect + buf.build/go/protoyaml v0.7.0 // indirect + cel.dev/expr v0.25.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dennwc/iters v1.2.2 // indirect + github.com/frostbyte73/core v0.1.1 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gammazero/deque v1.2.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/cel-go v0.28.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + github.com/jxskiss/base62 v1.1.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lithammer/shortuuid/v4 v4.2.0 // indirect + github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect + github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // indirect + github.com/livekit/psrpc v0.7.2 // indirect + github.com/magefile/mage v1.17.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.52.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/ice/v4 v4.2.7 // indirect + github.com/pion/interceptor v0.1.45 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.2 // indirect + github.com/pion/sctp v1.10.0 // indirect + github.com/pion/sdp/v3 v3.0.19 // indirect + github.com/pion/srtp/v3 v3.0.11 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.9 // indirect + github.com/pion/webrtc/v4 v4.2.15 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect + github.com/redis/go-redis/v9 v9.20.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3151643..a6f80dd 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,73 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= +buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= +buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= +buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= encore.dev v1.57.5 h1:gpcE2XF4Qvh0IzD+seLf9DPV9q8Mzr7bMaNJfpF2xuc= encore.dev v1.57.5/go.mod h1:lK8vSJG6uhYeUwT87/FEpcLdiN98QUcotd3gxRX0xDw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo= +github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= +github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= +github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -11,20 +76,169 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= +github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= +github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= +github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e h1:SkgQRcG2VYEhh80Qb/zYZo8rWKJzNfJcfUQnXe6su2M= +github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= +github.com/livekit/protocol v1.49.0 h1:Q5nthDO1v7c0JHiWjMhgUQTlsKmCsBL/KCKxdHVaz00= +github.com/livekit/protocol v1.49.0/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= +github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= +github.com/livekit/server-sdk-go/v2 v2.18.1 h1:/u0JVII+ErGCivHnAGr6di0wl0NYsfcRvDzEtTAFovo= +github.com/livekit/server-sdk-go/v2 v2.18.1/go.mod h1:su0IvJNWTFCHVwmqpsFvxHfXWs9pn26ms+cKBR1jILU= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= +github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= +github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= +github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo= +github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= +github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= +github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= +github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= +github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= +github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= +github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/profiles/migrations/11_personal_setup.up.sql b/profiles/migrations/11_personal_setup.up.sql new file mode 100644 index 0000000..c0a7ec5 --- /dev/null +++ b/profiles/migrations/11_personal_setup.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE personal_setup ( + user_id UUID PRIMARY KEY REFERENCES user_profiles (user_id) ON DELETE CASCADE, + can_broadcast BOOLEAN NOT NULL DEFAULT FALSE, + room_name TEXT NOT NULL DEFAULT '', + CONSTRAINT personal_setup_broadcast_room_check CHECK ( + (NOT can_broadcast AND room_name = '') OR + (can_broadcast AND octet_length(room_name) BETWEEN 1 AND 128 AND btrim(room_name) <> '') + ) +); diff --git a/profiles/personal_setup.go b/profiles/personal_setup.go new file mode 100644 index 0000000..9b6fea1 --- /dev/null +++ b/profiles/personal_setup.go @@ -0,0 +1,116 @@ +package profiles + +import ( + "context" + "errors" + "strings" + "unicode" + + authsvc "encore.app/auth" + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "encore.dev/storage/sqldb" + "encore.dev/types/uuid" +) + +// PersonalSetup holds the broadcasting configuration linked to a user profile. +type PersonalSetup struct { + UserID uuid.UUID `json:"user_id"` + CanBroadcast bool `json:"can_broadcast"` + RoomName string `json:"room_name"` +} + +type PersonalSetupParams struct { + CanBroadcast bool `json:"can_broadcast"` + RoomName string `json:"room_name"` +} + +func (p *PersonalSetupParams) Validate() error { + if p == nil { + return &errs.Error{Code: errs.InvalidArgument, Message: "personal setup params are required"} + } + if !p.CanBroadcast { + p.RoomName = "" + return nil + } + p.RoomName = strings.TrimSpace(p.RoomName) + if p.RoomName == "" || len(p.RoomName) > 128 || strings.ContainsAny(p.RoomName, "/\\") || strings.ContainsFunc(p.RoomName, unicode.IsControl) { + return &errs.Error{Code: errs.InvalidArgument, Message: "broadcasting requires a room name of 1-128 bytes without slashes or control characters"} + } + return nil +} + +// GetPersonalSetup returns the caller's setup, defaulting to broadcasting disabled. +// +//encore:api auth method=GET path=/profiles/personal-setup +func GetPersonalSetup(ctx context.Context) (*PersonalSetup, error) { + userID, err := authedUserID() + if err != nil { + return nil, err + } + return getPersonalSetup(ctx, userID) +} + +// GetUserPersonalSetup lets administrators read the selected user's setup. +// +//encore:api auth method=GET path=/profiles/personal-setup/:userID +func GetUserPersonalSetup(ctx context.Context, userID uuid.UUID) (*PersonalSetup, error) { + if _, ok := auth.UserID(); !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"} + } + data, _ := auth.Data().(*authsvc.AuthData) + if data == nil || !data.Role.IsAdmin() { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"} + } + return getPersonalSetup(ctx, userID) +} + +func getPersonalSetup(ctx context.Context, userID uuid.UUID) (*PersonalSetup, error) { + setup := PersonalSetup{UserID: userID} + err := db.QueryRow(ctx, ` + SELECT COALESCE(s.can_broadcast, FALSE), COALESCE(s.room_name, '') + FROM user_profiles p + LEFT JOIN personal_setup s ON s.user_id = p.user_id + WHERE p.user_id = $1 + `, userID).Scan(&setup.CanBroadcast, &setup.RoomName) + if errors.Is(err, sqldb.ErrNoRows) { + return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal setup") + } + return &setup, nil +} + +// UpsertPersonalSetup lets administrators assign or revoke a user's broadcasting setup. +// This stores configuration only; it does not create or delete a LiveKit room. +// +//encore:api auth method=PUT path=/profiles/personal-setup/:userID +func UpsertPersonalSetup(ctx context.Context, userID uuid.UUID, p *PersonalSetupParams) (*PersonalSetup, error) { + if _, ok := auth.UserID(); !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"} + } + data, _ := auth.Data().(*authsvc.AuthData) + if data == nil || !data.Role.IsAdmin() { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"} + } + if err := p.Validate(); err != nil { + return nil, err + } + setup := PersonalSetup{UserID: userID} + err := db.QueryRow(ctx, ` + INSERT INTO personal_setup (user_id, can_broadcast, room_name) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE + SET can_broadcast = EXCLUDED.can_broadcast, + room_name = EXCLUDED.room_name + RETURNING can_broadcast, room_name + `, userID, p.CanBroadcast, p.RoomName).Scan(&setup.CanBroadcast, &setup.RoomName) + if isForeignKeyViolation(err) { + return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to save personal setup") + } + return &setup, nil +} diff --git a/profiles/personal_setup_test.go b/profiles/personal_setup_test.go new file mode 100644 index 0000000..52a2d5b --- /dev/null +++ b/profiles/personal_setup_test.go @@ -0,0 +1,117 @@ +package profiles + +import ( + "context" + "strings" + "testing" + + authsvc "encore.app/auth" + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "encore.dev/et" + "encore.dev/types/uuid" +) + +func TestPersonalSetupValidation(t *testing.T) { + for _, p := range []*PersonalSetupParams{ + nil, + {CanBroadcast: true}, + {CanBroadcast: true, RoomName: " "}, + {CanBroadcast: true, RoomName: "a/b"}, + {CanBroadcast: true, RoomName: "a\x00b"}, + {CanBroadcast: true, RoomName: strings.Repeat("a", 129)}, + } { + if err := p.Validate(); errs.Code(err) != errs.InvalidArgument { + t.Errorf("expected invalid setup, got %v", err) + } + } + p := &PersonalSetupParams{CanBroadcast: true, RoomName: " demo "} + if err := p.Validate(); err != nil || p.RoomName != "demo" { + t.Fatalf("expected normalized room name: %+v, %v", p, err) + } + p.CanBroadcast = false + if err := p.Validate(); err != nil || p.RoomName != "" { + t.Fatalf("revoking broadcasting must clear the room: %+v, %v", p, err) + } +} + +func TestPersonalSetupPermissions(t *testing.T) { + defer et.OverrideAuthInfo("", nil) + ctx := context.Background() + et.OverrideAuthInfo("", nil) + if _, err := GetPersonalSetup(ctx); errs.Code(err) != errs.Unauthenticated { + t.Fatalf("expected unauthenticated, got %v", err) + } + if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{}); errs.Code(err) != errs.Unauthenticated { + t.Fatalf("expected unauthenticated, got %v", err) + } + if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.Unauthenticated { + t.Fatalf("anonymous admin read: %v", err) + } + et.OverrideAuthInfo("user", &authsvc.AuthData{Role: "user"}) + if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("non-admin read: %v", err) + } + if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{CanBroadcast: true, RoomName: "demo"}); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("users must not grant themselves broadcasting: %v", err) + } +} + +func TestPersonalSetupPersistence(t *testing.T) { + ctx := context.Background() + testDB, err := et.NewTestDatabase(ctx, "profiles") + if err != nil { + t.Fatal(err) + } + originalDB := db + db = testDB + defer func() { db = originalDB; et.OverrideAuthInfo("", nil) }() + userID, err := uuid.NewV4() + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Setup Test', 'setup@example.invalid')`, userID) + if err != nil { + t.Fatal(err) + } + et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"}) + setup, err := GetPersonalSetup(ctx) + if err != nil || setup.CanBroadcast || setup.RoomName != "" { + t.Fatalf("expected disabled default setup: %+v, %v", setup, err) + } + et.OverrideAuthInfo("admin", &authsvc.AuthData{Role: "admin"}) + if _, err := UpsertPersonalSetup(ctx, userID, &PersonalSetupParams{CanBroadcast: true, RoomName: "demo"}); err != nil { + t.Fatal(err) + } + setup, err = GetUserPersonalSetup(ctx, userID) + if err != nil || setup.UserID != userID || !setup.CanBroadcast || setup.RoomName != "demo" { + t.Fatalf("admin must read selected user's settings: %+v, %v", setup, err) + } + if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.NotFound { + t.Fatalf("missing profile read: %v", err) + } + et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"}) + setup, err = GetPersonalSetup(ctx) + if err != nil || !setup.CanBroadcast || setup.RoomName != "demo" || setup.UserID != userID { + t.Fatalf("expected persisted setup: %+v, %v", setup, err) + } + et.OverrideAuthInfo("admin", &authsvc.AuthData{Role: "admin"}) + setup, err = UpsertPersonalSetup(ctx, userID, &PersonalSetupParams{CanBroadcast: false, RoomName: "demo"}) + if err != nil || setup.CanBroadcast || setup.RoomName != "" { + t.Fatalf("expected revoked setup: %+v, %v", setup, err) + } + if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{}); errs.Code(err) != errs.NotFound { + t.Fatalf("expected missing profile: %v", err) + } + if _, err := db.Exec(ctx, `DELETE FROM user_profiles WHERE user_id = $1`, userID); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRow(ctx, `SELECT COUNT(*) FROM personal_setup WHERE user_id = $1`, userID).Scan(&count); err != nil || count != 0 { + t.Fatalf("setup must be deleted with profile: count=%d, err=%v", count, err) + } + et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"}) + if _, err := GetPersonalSetup(ctx); errs.Code(err) != errs.NotFound { + t.Fatalf("expected missing profile: %v", err) + } +} diff --git a/videochat/README.md b/videochat/README.md new file mode 100644 index 0000000..a17333a --- /dev/null +++ b/videochat/README.md @@ -0,0 +1,61 @@ +# Broadcast LiveKit (uno a molti) + +Configurare i secret Encore `LiveKitURL`, `LiveKitAPIKey` e `LiveKitAPISecret` sul server. +Il frontend usa solo il token partecipante, mai la API secret. + +## Accesso + +- Solo gli amministratori possono creare stanze (`POST /videochat/rooms`). +- Il creatore è l'unico proprietario; anche un altro amministratore entra come spettatore. +- Tutti gli utenti autenticati possono elencare i broadcast e richiedere un token. +- Il proprietario pubblica audio, videocamera e schermo. Gli spettatori hanno + `canPublish=false`, `canPublishData=false`, `canSubscribe=true` nel JWT firmato. +- Nessun token frontend ha permessi amministrativi o modifica metadati. +- Elenco partecipanti, disconnessione partecipanti e chiusura richiedono il proprietario. + +## API + +| Metodo | Percorso | Accesso | +| --- | --- | --- | +| GET | `/videochat/status` | Pubblico | +| POST | `/videochat/rooms` | Admin (diventa proprietario) | +| GET | `/videochat/rooms` | Autenticato | +| POST | `/videochat/token` | Autenticato | +| DELETE | `/videochat/rooms/:roomName` | Proprietario | +| GET | `/videochat/rooms/:roomName/participants` | Proprietario | +| DELETE | `/videochat/rooms/:roomName/participants/:identity` | Proprietario | + +La lista include i broadcast presenti su LiveKit (`active: true`) e le stanze +di `personal_setup` con `can_broadcast: true` non ancora presenti (`active: false`). +Il confronto usa il nome tecnico oppure proprietario e nome visualizzato. +Le stanze inattive riportano il nome configurato, il proprietario e `mode: "broadcast"`; +SID, partecipanti e data di creazione restano vuoti o zero. Non vengono create +su LiveKit e non possono ricevere un token finché non sono avviate. + +Le richieste usano `Authorization: Bearer ` o il cookie `session`. +Creazione: `{"name":"Titolo della diretta","max_participants":0,"empty_timeout":300}`. +Zero nei campi numerici usa i valori predefiniti di LiveKit; il massimo comprende +anche il proprietario. Il timeout è in secondi. + +La risposta stanza aggiunge `display_name`, `owner_id`, `mode: "broadcast"`. +`name` è un identificativo univoco generato dal server: usarlo nei percorsi API e +in `POST /videochat/token` con `{"room_name":""}`. +La risposta token include `role: "owner" | "viewer"`, `owner_id`, `server_url`, +`participant_token`, `identity`, `room_name`, `expires_in`. + +## Ciclo di vita + +Le stanze sono effimere. Titolo e proprietario sono conservati nei metadati +LiveKit per la durata della stanza, anche se Encore viene riavviato. Quando +LiveKit elimina una stanza vuota, occorre crearne una nuova; non è un archivio +persistente delle trasmissioni. I nomi interni non vengono riutilizzati. +Le vecchie stanze senza metadati broadcast non compaiono nella lista e non +ricevono nuovi token tramite questa API; non vengono eliminate automaticamente. +I vecchi token meeting già emessi mantengono i permessi fino alla loro scadenza. + +La disconnessione di uno spettatore non è un ban: può rientrare. La chiusura +elimina la stanza e disconnette tutti. Su LiveKit self-hosted i JWT già emessi +non sono revocabili da queste API: fino alla scadenza (10 minuti) possono ancora +consentire accesso o ricreazione della vecchia stanza interna. Tale stanza non +avrà metadati broadcast, non apparirà nella lista e non riceverà nuovi token. +Per un blocco irrevocabile servono controllo dell'ammissione/revoca aggiuntivi. diff --git a/videochat/livekit.go b/videochat/livekit.go new file mode 100644 index 0000000..d615479 --- /dev/null +++ b/videochat/livekit.go @@ -0,0 +1,145 @@ +package videochat + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "strings" + "time" + "unicode" + + authsvc "encore.app/auth" + "encore.dev/beta/auth" + "encore.dev/beta/errs" + lkauth "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" + "github.com/twitchtv/twirp" +) + +var secrets struct { + LiveKitURL string + LiveKitAPIKey string + LiveKitAPISecret string +} + +const tokenTTL = 10 * time.Minute +const apiTimeout = 10 * time.Second + +func liveKitClient() (*lksdk.RoomServiceClient, string, error) { + serverURL, err := websocketURL(secrets.LiveKitURL) + if err != nil || strings.TrimSpace(secrets.LiveKitAPIKey) == "" || strings.TrimSpace(secrets.LiveKitAPISecret) == "" { + return nil, "", &errs.Error{Code: errs.FailedPrecondition, Message: "LiveKit is not configured"} + } + return lksdk.NewRoomServiceClient(serverURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret), serverURL, nil +} + +func websocketURL(raw string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("invalid LiveKit URL") + } + switch u.Scheme { + case "http", "ws": + u.Scheme = "ws" + case "https", "wss": + u.Scheme = "wss" + default: + return "", errors.New("invalid LiveKit URL scheme") + } + return strings.TrimRight(u.String(), "/"), nil +} + +func requireAdmin() error { + if _, ok := auth.UserID(); !ok { + return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + data, _ := auth.Data().(*authsvc.AuthData) + if data == nil || !data.Role.IsAdmin() { + return &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"} + } + return nil +} + +func validateRoomName(name string) error { + if strings.TrimSpace(name) == "" || name != strings.TrimSpace(name) || len(name) > 128 || strings.ContainsAny(name, "/\\") || strings.ContainsFunc(name, unicode.IsControl) { + return &errs.Error{Code: errs.InvalidArgument, Message: "room name must contain 1-128 bytes, without slashes, surrounding whitespace or control characters"} + } + return nil +} + +func liveKitError(err error) error { + if err == nil { + return nil + } + code := errs.Unavailable + var rpcErr twirp.Error + if errors.As(err, &rpcErr) { + switch rpcErr.Code() { + case twirp.NotFound: + code = errs.NotFound + case twirp.InvalidArgument: + code = errs.InvalidArgument + case twirp.AlreadyExists: + code = errs.AlreadyExists + } + } + return errs.WrapCode(err, code, "LiveKit request failed") +} + +// Ownership is written only by this server when creating a fresh LiveKit room. +type broadcastMetadata struct { + Mode string `json:"mode"` + OwnerID string `json:"owner_id"` + DisplayName string `json:"display_name"` +} + +func readBroadcast(room *livekit.Room) (broadcastMetadata, bool) { + var metadata broadcastMetadata + err := json.Unmarshal([]byte(room.Metadata), &metadata) + return metadata, err == nil && metadata.Mode == "broadcast" && metadata.OwnerID != "" && metadata.DisplayName != "" +} + +func findBroadcast(ctx context.Context, client *lksdk.RoomServiceClient, name string) (*livekit.Room, broadcastMetadata, error) { + result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{Names: []string{name}}) + if err != nil { + return nil, broadcastMetadata{}, liveKitError(err) + } + for _, room := range result.Rooms { + if room.Name == name { + if metadata, ok := readBroadcast(room); ok { + return room, metadata, nil + } + } + } + return nil, broadcastMetadata{}, &errs.Error{Code: errs.NotFound, Message: "broadcast room not found"} +} + +func requireRoomOwner(ctx context.Context, client *lksdk.RoomServiceClient, name string) error { + uid, ok := auth.UserID() + if !ok { + return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + _, metadata, err := findBroadcast(ctx, client, name) + if err != nil { + return err + } + if metadata.OwnerID != string(uid) { + return &errs.Error{Code: errs.PermissionDenied, Message: "room owner required"} + } + return nil +} + +func signJoinToken(key, secret, identity, room string, owner bool) (string, error) { + grant := &lkauth.VideoGrant{RoomJoin: true, Room: room} + grant.SetCanPublish(owner) + grant.SetCanSubscribe(true) + grant.SetCanPublishData(false) + grant.SetCanUpdateOwnMetadata(false) + return lkauth.NewAccessToken(key, secret). + SetIdentity(identity). + SetValidFor(tokenTTL). + SetVideoGrant(grant). + ToJWT() +} diff --git a/videochat/livekit_test.go b/videochat/livekit_test.go new file mode 100644 index 0000000..279a898 --- /dev/null +++ b/videochat/livekit_test.go @@ -0,0 +1,325 @@ +package videochat + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + authsvc "encore.app/auth" + "encore.dev/beta/errs" + "encore.dev/et" + lkauth "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" + "github.com/twitchtv/twirp" + "google.golang.org/protobuf/proto" +) + +func TestJoinTokenPermissions(t *testing.T) { + token, err := signJoinToken("test-key", "test-secret", "user-123", "demo", true) + if err != nil { + t.Fatal(err) + } + verifier, err := lkauth.ParseAPIToken(token) + if err != nil { + t.Fatal(err) + } + claims, grants, err := verifier.Verify("test-secret") + if err != nil { + t.Fatal(err) + } + if verifier.APIKey() != "test-key" || grants.Identity != "user-123" { + t.Fatal("unexpected token issuer or identity") + } + g := grants.Video + if g == nil || g.Room != "demo" || !g.RoomJoin || !g.GetCanPublish() || !g.GetCanSubscribe() || g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() { + t.Fatalf("unexpected participant grants: %+v", g) + } + if g.RoomAdmin || g.RoomCreate || g.RoomList || g.RoomRecord { + t.Fatal("participant must not receive administrative grants") + } + if remaining := time.Until(claims.ExpiresAt.Time); remaining < tokenTTL-5*time.Second || remaining > tokenTTL { + t.Fatalf("unexpected token lifetime: %v", remaining) + } + if _, _, err := verifier.Verify("wrong-secret"); err == nil { + t.Fatal("token must reject an incorrect signing secret") + } +} + +func TestValidation(t *testing.T) { + for _, name := range []string{"", " ", " demo", "demo ", "a/b", "a\\b", "a\x00b", strings.Repeat("a", 129)} { + if err := validateRoomName(name); errs.Code(err) != errs.InvalidArgument { + t.Errorf("expected invalid room name %q, got %v", name, err) + } + } + if err := validateRoomName("demo-123"); err != nil { + t.Fatal(err) + } + for _, raw := range []string{"", "example.com", "ftp://example.com", "https://user:pass@example.com", "https://example.com?key=secret"} { + if _, err := websocketURL(raw); err == nil { + t.Errorf("accepted invalid URL %q", raw) + } + } + for raw, want := range map[string]string{"https://example.com/": "wss://example.com", "http://localhost:7880": "ws://localhost:7880", "wss://example.com": "wss://example.com"} { + if got, err := websocketURL(raw); err != nil || got != want { + t.Errorf("websocketURL(%q) = %q, %v; want %q", raw, got, err, want) + } + } +} + +func TestManagementRequiresAuthentication(t *testing.T) { + defer et.OverrideAuthInfo("", nil) + et.OverrideAuthInfo("", nil) + ctx := context.Background() + _, createErr := CreateRoom(ctx, &CreateRoomParams{Name: "demo"}) + _, listErr := ListRooms(ctx) + deleteErr := DeleteRoom(ctx, "demo") + _, participantsErr := ListParticipants(ctx, "demo") + removeErr := RemoveParticipant(ctx, "demo", "user-456") + for _, err := range []error{createErr, listErr, deleteErr, participantsErr, removeErr} { + if errs.Code(err) != errs.Unauthenticated { + t.Fatalf("expected unauthenticated, got %v", err) + } + } + et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"}) + if _, err := CreateRoom(ctx, &CreateRoomParams{Name: "demo"}); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("only administrators can create rooms: %v", err) + } +} + +func TestViewerTokenCannotPublish(t *testing.T) { + token, err := signJoinToken("test-key", "test-secret", "viewer", "demo", false) + if err != nil { + t.Fatal(err) + } + verifier, err := lkauth.ParseAPIToken(token) + if err != nil { + t.Fatal(err) + } + _, grants, err := verifier.Verify("test-secret") + if err != nil { + t.Fatal(err) + } + g := grants.Video + if g.GetCanPublish() || g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() || !g.GetCanSubscribe() || g.RoomAdmin || g.RoomCreate { + t.Fatalf("unsafe viewer permissions: %+v", g) + } +} + +func TestCreateTokenChecksRoomAndIdentity(t *testing.T) { + original := secrets + defer func() { secrets = original; et.OverrideAuthInfo("", nil) }() + roomExists := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/twirp/livekit.RoomService/ListRooms" { + t.Errorf("unexpected LiveKit operation: %s", r.URL.Path) + } + body, err := io.ReadAll(r.Body) + var request livekit.ListRoomsRequest + if err != nil || proto.Unmarshal(body, &request) != nil || len(request.Names) != 1 || request.Names[0] != "demo" { + t.Error("room lookup must filter by the requested name") + } + verifier, err := lkauth.ParseAPIToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusUnauthorized) + return + } + _, grants, err := verifier.Verify("test-secret") + if err != nil || grants.Video == nil || !grants.Video.RoomList { + t.Error("missing signed room-list permission") + } + w.Header().Set("Content-Type", "application/protobuf") + result := &livekit.ListRoomsResponse{} + if roomExists { + result.Rooms = []*livekit.Room{{Name: "demo", Metadata: `{"mode":"broadcast","owner_id":"owner-123","display_name":"Demo"}`}} + } + payload, err := proto.Marshal(result) + if err != nil { + t.Error(err) + } + _, _ = w.Write(payload) + })) + defer server.Close() + secrets.LiveKitURL = server.URL + secrets.LiveKitAPIKey = "test-key" + secrets.LiveKitAPISecret = "test-secret" + ctx := context.Background() + et.OverrideAuthInfo("", nil) + if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.Unauthenticated { + t.Fatalf("expected unauthenticated, got %v", err) + } + et.OverrideAuthInfo("user-123", &authsvc.AuthData{Role: "user"}) + response, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}) + if err != nil { + t.Fatal(err) + } + if response.Role != "viewer" || response.OwnerID != "owner-123" || response.Identity != "user-123" || response.ExpiresIn != 600 || !strings.HasPrefix(response.ServerURL, "ws://") { + t.Fatalf("unexpected token response: %+v", response) + } + verifier, err := lkauth.ParseAPIToken(response.ParticipantToken) + if err != nil { + t.Fatal(err) + } + _, grants, err := verifier.Verify("test-secret") + if err != nil || grants.Identity != "user-123" || grants.Video.Room != "demo" || grants.Video.GetCanPublish() { + t.Fatal("token must be bound to the authenticated user and requested room") + } + for _, role := range []string{"user", "admin"} { + if role == "admin" { + et.OverrideAuthInfo("other-admin", &authsvc.AuthData{Role: "admin"}) + } + if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("non-owner must not manage rooms: %v", err) + } + if err := DeleteRoom(ctx, "demo"); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("non-owner delete: %v", err) + } + if _, err := ListParticipants(ctx, "demo"); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("non-owner participants: %v", err) + } + if err := RemoveParticipant(ctx, "demo", "viewer"); errs.Code(err) != errs.PermissionDenied { + t.Fatalf("non-owner removal: %v", err) + } + } + et.OverrideAuthInfo("owner-123", &authsvc.AuthData{Role: "admin"}) + if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); err != nil { + t.Fatal(err) + } + ownerResponse, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}) + if err != nil || ownerResponse.Role != "owner" { + t.Fatalf("owner token: %v", err) + } + ownerVerifier, _ := lkauth.ParseAPIToken(ownerResponse.ParticipantToken) + _, ownerGrants, err := ownerVerifier.Verify("test-secret") + if err != nil || !ownerGrants.Video.GetCanPublish() { + t.Fatal("owner must be able to publish") + } + + roomExists = false + if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.NotFound { + t.Fatalf("expected missing room, got %v", err) + } +} + +func TestLiveKitErrorMapping(t *testing.T) { + for rpc, want := range map[twirp.ErrorCode]errs.ErrCode{twirp.NotFound: errs.NotFound, twirp.InvalidArgument: errs.InvalidArgument, twirp.Unavailable: errs.Unavailable} { + if err := liveKitError(twirp.NewError(rpc, "upstream error")); errs.Code(err) != want { + t.Errorf("expected %v, got %v", want, err) + } + } +} + +func mustTestClient(t *testing.T) *lksdk.RoomServiceClient { + t.Helper() + client, _, err := liveKitClient() + if err != nil { + t.Fatal(err) + } + return client +} + +func TestLegacyRoomsHaveNoInferredOwner(t *testing.T) { + for _, metadata := range []string{"", "{}", `{"mode":"broadcast"}`, `{"mode":"meeting","owner_id":"admin","display_name":"demo"}`} { + if _, ok := readBroadcast(&livekit.Room{Metadata: metadata}); ok { + t.Fatalf("accepted unmanaged room: %s", metadata) + } + } +} + +// Exercise creation, listing, ownership and moderation against the real SDK's HTTP transport. +func TestBroadcastLifecycle(t *testing.T) { + useTestProfilesDB(t) + original := secrets + defer func() { secrets = original; et.OverrideAuthInfo("", nil) }() + var stored *livekit.Room + removed := "" + deleted := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + var response proto.Message + switch r.URL.Path { + case "/twirp/livekit.RoomService/CreateRoom": + var request livekit.CreateRoomRequest + if err := proto.Unmarshal(data, &request); err != nil { + t.Error(err) + } + stored = &livekit.Room{Sid: "sid-1", Name: request.Name, Metadata: request.Metadata, MaxParticipants: request.MaxParticipants} + response = stored + case "/twirp/livekit.RoomService/ListRooms": + result := &livekit.ListRoomsResponse{} + if stored != nil { + result.Rooms = []*livekit.Room{stored, {Name: "legacy-room"}} + } + response = result + case "/twirp/livekit.RoomService/ListParticipants": + response = &livekit.ListParticipantsResponse{Participants: []*livekit.ParticipantInfo{{Sid: "viewer-sid", Identity: "viewer"}}} + case "/twirp/livekit.RoomService/RemoveParticipant": + var request livekit.RoomParticipantIdentity + if err := proto.Unmarshal(data, &request); err != nil { + t.Error(err) + } + removed = request.Identity + response = &livekit.RemoveParticipantResponse{} + case "/twirp/livekit.RoomService/DeleteRoom": + stored = nil + deleted = true + response = &livekit.DeleteRoomResponse{} + default: + t.Errorf("unexpected operation: %s", r.URL.Path) + w.WriteHeader(404) + return + } + w.Header().Set("Content-Type", "application/protobuf") + payload, _ := proto.Marshal(response) + _, _ = w.Write(payload) + })) + defer server.Close() + secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret" + ctx := context.Background() + et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"}) + created, err := CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast", MaxParticipants: 20}) + if err != nil { + t.Fatal(err) + } + if created.OwnerID != "owner" || created.DisplayName != "My broadcast" || created.Mode != "broadcast" || !strings.HasPrefix(created.Name, "broadcast-") || created.MaxParticipants != 20 { + t.Fatalf("invalid created room: %+v", created) + } + firstName := created.Name + created, err = CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast"}) + if err != nil || created.Name == firstName { + t.Fatal("duplicate titles must have distinct room identities") + } + et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"}) + listed, err := ListRooms(ctx) + if err != nil || len(listed.Rooms) != 1 || listed.Rooms[0].OwnerID != "owner" { + t.Fatalf("viewer must see broadcasts only: %+v, %v", listed, err) + } + if err := DeleteRoom(ctx, created.Name); errs.Code(err) != errs.PermissionDenied || deleted { + t.Fatal("viewer deleted the room") + } + if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.PermissionDenied || removed != "" { + t.Fatal("viewer removed the owner") + } + et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"}) + members, err := ListParticipants(ctx, created.Name) + if err != nil || len(members.Participants) != 1 { + t.Fatalf("owner list participants: %v", err) + } + if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.InvalidArgument { + t.Fatal("owner self-removal must be rejected") + } + if err := RemoveParticipant(ctx, created.Name, "viewer"); err != nil || removed != "viewer" { + t.Fatalf("owner remove viewer: %v", err) + } + if err := DeleteRoom(ctx, created.Name); err != nil || !deleted { + t.Fatalf("owner close: %v", err) + } + if _, err := CreateToken(ctx, &TokenParams{RoomName: created.Name}); errs.Code(err) != errs.NotFound { + t.Fatal("closed room must not receive new tokens") + } +} diff --git a/videochat/open_room.go b/videochat/open_room.go new file mode 100644 index 0000000..22c21d5 --- /dev/null +++ b/videochat/open_room.go @@ -0,0 +1,79 @@ +package videochat + +import ( + "context" + "encoding/json" + + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "github.com/livekit/protocol/livekit" +) + +// OpenRoom returns an existing broadcast or provisions an enabled personal setup. +// The owner comes exclusively from administrator-managed settings, never the caller. +// +//encore:api auth method=POST path=/videochat/rooms/:roomName/open +func OpenRoom(ctx context.Context, roomName string) (*Room, error) { + if _, ok := auth.UserID(); !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + if err := validateRoomName(roomName); err != nil { + return nil, err + } + client, _, err := liveKitClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + // Serialize lookup and creation across application instances for this name. + tx, err := profilesDB.Begin(ctx) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to open room") + } + defer tx.Rollback() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "videochat/open/"+roomName); err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to lock room creation") + } + result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{}) + if err != nil { + return nil, liveKitError(err) + } + for _, room := range result.Rooms { + if room.Name == roomName { + if _, ok := readBroadcast(room); !ok { + return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is already used by an unmanaged room"} + } + return roomResponse(room), nil + } + } + + // Multiple enabled owners with the same name are ambiguous: do not pick one. + var owner string + var count int + err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MIN(user_id::text), '') FROM personal_setup WHERE can_broadcast = TRUE AND room_name = $1`, roomName).Scan(&count, &owner) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to fetch configured room") + } + if count == 0 { + return nil, &errs.Error{Code: errs.NotFound, Message: "no enabled broadcast setup for this room"} + } + if count != 1 { + return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is configured for more than one owner"} + } + for _, room := range result.Rooms { + if metadata, ok := readBroadcast(room); ok && metadata.OwnerID == owner && metadata.DisplayName == roomName { + return roomResponse(room), nil + } + } + metadata, err := json.Marshal(broadcastMetadata{Mode: "broadcast", OwnerID: owner, DisplayName: roomName}) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to encode room metadata") + } + room, err := client.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: roomName, Metadata: string(metadata)}) + if err != nil { + return nil, liveKitError(err) + } + return roomResponse(room), nil +} diff --git a/videochat/open_room_test.go b/videochat/open_room_test.go new file mode 100644 index 0000000..0a2d4e3 --- /dev/null +++ b/videochat/open_room_test.go @@ -0,0 +1,129 @@ +package videochat + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + authsvc "encore.app/auth" + "encore.dev/beta/errs" + "encore.dev/et" + "github.com/livekit/protocol/livekit" + "google.golang.org/protobuf/proto" +) + +func TestOpenRoomCreatesAndReusesConfiguredRoom(t *testing.T) { + useTestProfilesDB(t) + ctx := context.Background() + const owner = "00000000-0000-4000-8000-000000000001" + if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Owner', 'open@example.invalid')`, owner); err != nil { + t.Fatal(err) + } + if _, err := profilesDB.Exec(ctx, `INSERT INTO personal_setup (user_id, can_broadcast, room_name) VALUES ($1, TRUE, 'Configured room')`, owner); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var rooms []*livekit.Room + creates := 0 + failList := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + var response proto.Message + switch r.URL.Path { + case "/twirp/livekit.RoomService/ListRooms": + if failList { + w.WriteHeader(503) + return + } + response = &livekit.ListRoomsResponse{Rooms: rooms} + case "/twirp/livekit.RoomService/CreateRoom": + body, _ := io.ReadAll(r.Body) + var request livekit.CreateRoomRequest + if err := proto.Unmarshal(body, &request); err != nil { + t.Error(err) + } + creates++ + room := &livekit.Room{Sid: "created-sid", Name: request.Name, Metadata: request.Metadata} + rooms = append(rooms, room) + response = room + default: + t.Errorf("unexpected operation: %s", r.URL.Path) + w.WriteHeader(404) + return + } + payload, _ := proto.Marshal(response) + w.Header().Set("Content-Type", "application/protobuf") + _, _ = w.Write(payload) + })) + defer server.Close() + original := secrets + defer func() { secrets = original; et.OverrideAuthInfo("", nil) }() + secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret" + et.OverrideAuthInfo("", nil) + if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.Unauthenticated { + t.Fatalf("anonymous open: %v", err) + } + et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"}) + if _, err := OpenRoom(ctx, "unknown"); errs.Code(err) != errs.NotFound { + t.Fatalf("unconfigured room: %v", err) + } + if _, err := OpenRoom(ctx, "bad/name"); errs.Code(err) != errs.InvalidArgument { + t.Fatalf("invalid name: %v", err) + } + + // Concurrent viewers may provision the configured room, but never become its owner. + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"}) + room, err := OpenRoom(ctx, "Configured room") + if err != nil { + t.Error(err) + return + } + if !room.Active || room.OwnerID != owner || room.Name != "Configured room" || room.SID != "created-sid" { + t.Errorf("invalid room: %+v", room) + } + }() + } + wg.Wait() + if creates != 1 { + t.Fatalf("expected a single creation, got %d", creates) + } + token, err := CreateToken(ctx, &TokenParams{RoomName: "Configured room"}) + if err != nil || token.Role != "viewer" || token.OwnerID != owner { + t.Fatalf("opener must remain viewer: %+v, %v", token, err) + } + + mu.Lock() + failList = true + mu.Unlock() + if _, err := OpenRoom(ctx, "Configured room"); err == nil { + t.Fatal("upstream errors must not be treated as missing rooms") + } + if creates != 1 { + t.Fatal("upstream failure created a room") + } + mu.Lock() + failList = false + rooms = []*livekit.Room{{Name: "Configured room"}} + mu.Unlock() + if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.FailedPrecondition { + t.Fatalf("unmanaged collision: %v", err) + } + mu.Lock() + rooms = nil + mu.Unlock() + if _, err := profilesDB.Exec(ctx, `UPDATE personal_setup SET can_broadcast = FALSE, room_name = '' WHERE user_id = $1`, owner); err != nil { + t.Fatal(err) + } + if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.NotFound { + t.Fatalf("disabled room was created: %v", err) + } +} diff --git a/videochat/participants.go b/videochat/participants.go new file mode 100644 index 0000000..6e7104a --- /dev/null +++ b/videochat/participants.go @@ -0,0 +1,84 @@ +package videochat + +import ( + "context" + "strings" + + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "github.com/livekit/protocol/livekit" +) + +type Participant struct { + SID string `json:"sid"` + Identity string `json:"identity"` + Name string `json:"name"` + State string `json:"state"` +} + +type ListParticipantsResponse struct { + Participants []*Participant `json:"participants"` +} + +// ListParticipants lists participants in a room. Requires the room owner. +// +//encore:api auth method=GET path=/videochat/rooms/:roomName/participants +func ListParticipants(ctx context.Context, roomName string) (*ListParticipantsResponse, error) { + if _, ok := auth.UserID(); !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + if err := validateRoomName(roomName); err != nil { + return nil, err + } + client, _, err := liveKitClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + if err := requireRoomOwner(ctx, client, roomName); err != nil { + return nil, err + } + result, err := client.ListParticipants(ctx, &livekit.ListParticipantsRequest{Room: roomName}) + if err != nil { + return nil, liveKitError(err) + } + response := &ListParticipantsResponse{Participants: make([]*Participant, 0, len(result.Participants))} + for _, p := range result.Participants { + response.Participants = append(response.Participants, &Participant{ + SID: p.Sid, Identity: p.Identity, Name: p.Name, State: p.State.String(), + }) + } + return response, nil +} + +// RemoveParticipant disconnects a participant. Requires the room owner. +// It does not ban the user from requesting another token. +// +//encore:api auth method=DELETE path=/videochat/rooms/:roomName/participants/:identity +func RemoveParticipant(ctx context.Context, roomName, identity string) error { + if _, ok := auth.UserID(); !ok { + return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + if err := validateRoomName(roomName); err != nil { + return err + } + if strings.TrimSpace(identity) == "" { + return &errs.Error{Code: errs.InvalidArgument, Message: "participant identity is required"} + } + client, _, err := liveKitClient() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + if err := requireRoomOwner(ctx, client, roomName); err != nil { + return err + } + uid, _ := auth.UserID() + if identity == string(uid) { + return &errs.Error{Code: errs.InvalidArgument, Message: "owner cannot remove themselves; leave or close the room instead"} + } + _, err = client.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{Room: roomName, Identity: identity}) + return liveKitError(err) +} diff --git a/videochat/rooms.go b/videochat/rooms.go new file mode 100644 index 0000000..2993e73 --- /dev/null +++ b/videochat/rooms.go @@ -0,0 +1,173 @@ +package videochat + +import ( + "context" + "encoding/json" + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "encore.dev/storage/sqldb" + "encore.dev/types/uuid" + + "github.com/livekit/protocol/livekit" +) + +var profilesDB = sqldb.Named("profiles") + +type CreateRoomParams struct { + Name string `json:"name"` + MaxParticipants uint32 `json:"max_participants"` + EmptyTimeout uint32 `json:"empty_timeout"` +} + +type Room struct { + Active bool `json:"active"` + OwnerID string `json:"owner_id"` + DisplayName string `json:"display_name"` + Mode string `json:"mode"` + SID string `json:"sid"` + Name string `json:"name"` + NumParticipants uint32 `json:"num_participants"` + MaxParticipants uint32 `json:"max_participants"` + CreationTime int64 `json:"creation_time"` +} + +type ListRoomsResponse struct { + Rooms []*Room `json:"rooms"` +} + +func roomResponse(room *livekit.Room) *Room { + metadata, _ := readBroadcast(room) + return &Room{Active: true, OwnerID: metadata.OwnerID, DisplayName: metadata.DisplayName, Mode: metadata.Mode, SID: room.Sid, Name: room.Name, NumParticipants: room.NumParticipants, + MaxParticipants: room.MaxParticipants, CreationTime: room.CreationTime} +} + +// CreateRoom creates a LiveKit room. Requires an administrator. +// +//encore:api auth method=POST path=/videochat/rooms +func CreateRoom(ctx context.Context, p *CreateRoomParams) (*Room, error) { + if err := requireAdmin(); err != nil { + return nil, err + } + if err := validateRoomName(p.Name); err != nil { + return nil, err + } + client, _, err := liveKitClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + uid, _ := auth.UserID() + id, err := uuid.NewV4() + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to create room identifier") + } + metadata, err := json.Marshal(broadcastMetadata{Mode: "broadcast", OwnerID: string(uid), DisplayName: p.Name}) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to encode room metadata") + } + room, err := client.CreateRoom(ctx, &livekit.CreateRoomRequest{ + Name: "broadcast-" + id.String(), Metadata: string(metadata), MaxParticipants: p.MaxParticipants, EmptyTimeout: p.EmptyTimeout, + }) + if err != nil { + return nil, liveKitError(err) + } + return roomResponse(room), nil +} + +// ListRooms lists live broadcasts and enabled personal setups for authenticated viewers. +// Configured rooms absent from LiveKit are returned with active=false. +// +//encore:api auth method=GET path=/videochat/rooms +func ListRooms(ctx context.Context) (*ListRoomsResponse, error) { + if _, ok := auth.UserID(); !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + client, _, err := liveKitClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{}) + if err != nil { + return nil, liveKitError(err) + } + response := &ListRoomsResponse{Rooms: make([]*Room, 0, len(result.Rooms))} + for _, room := range result.Rooms { + if _, ok := readBroadcast(room); ok { + response.Rooms = append(response.Rooms, roomResponse(room)) + } + } + configured, err := configuredBroadcastRooms(ctx) + if err != nil { + return nil, err + } + response.Rooms = mergeBroadcastRooms(response.Rooms, configured) + return response, nil +} + +func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) { + rows, err := profilesDB.Query(ctx, ` + SELECT user_id::text, room_name + FROM personal_setup + WHERE can_broadcast = TRUE AND room_name <> '' + ORDER BY room_name, user_id + `) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms") + } + defer rows.Close() + rooms := make([]*Room, 0) + for rows.Next() { + room := &Room{Mode: "broadcast"} + if err := rows.Scan(&room.OwnerID, &room.Name); err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to read configured broadcast room") + } + room.DisplayName = room.Name + rooms = append(rooms, room) + } + if err := rows.Err(); err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms") + } + return rooms, nil +} + +func mergeBroadcastRooms(active, configured []*Room) []*Room { + type ownerTitle struct{ owner, title string } + names := make(map[string]bool, len(active)) + titles := make(map[ownerTitle]bool, len(active)) + for _, room := range active { + names[room.Name] = true + titles[ownerTitle{room.OwnerID, room.DisplayName}] = true + } + for _, room := range configured { + if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] { + active = append(active, room) + } + } + return active +} + +// DeleteRoom closes a room and disconnects its participants. Requires the room owner. +// +//encore:api auth method=DELETE path=/videochat/rooms/:roomName +func DeleteRoom(ctx context.Context, roomName string) error { + if _, ok := auth.UserID(); !ok { + return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + if err := validateRoomName(roomName); err != nil { + return err + } + client, _, err := liveKitClient() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + if err := requireRoomOwner(ctx, client, roomName); err != nil { + return err + } + _, err = client.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: roomName}) + return liveKitError(err) +} diff --git a/videochat/rooms_test.go b/videochat/rooms_test.go new file mode 100644 index 0000000..d54640b --- /dev/null +++ b/videochat/rooms_test.go @@ -0,0 +1,99 @@ +package videochat + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + authsvc "encore.app/auth" + "encore.dev/et" + "github.com/livekit/protocol/livekit" + "google.golang.org/protobuf/proto" +) + +func useTestProfilesDB(t *testing.T) { + t.Helper() + testDB, err := et.NewTestDatabase(context.Background(), "profiles") + if err != nil { + t.Fatal(err) + } + original := profilesDB + profilesDB = testDB + t.Cleanup(func() { profilesDB = original }) +} + +func TestListRoomsIncludesInactiveSetups(t *testing.T) { + useTestProfilesDB(t) + ctx := context.Background() + const owner = "00000000-0000-4000-8000-000000000001" + const offline = "00000000-0000-4000-8000-000000000002" + const disabled = "00000000-0000-4000-8000-000000000003" + const sameTitle = "00000000-0000-4000-8000-000000000004" + for _, id := range []string{owner, offline, disabled, sameTitle} { + if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Test', $2)`, id, id+"@example.invalid"); err != nil { + t.Fatal(err) + } + } + for _, setup := range []struct { + id, name string + enabled bool + }{ + {owner, "Live show", true}, {offline, "Offline show", true}, + {disabled, "", false}, {sameTitle, "Live show", true}, + } { + if _, err := profilesDB.Exec(ctx, `INSERT INTO personal_setup (user_id, can_broadcast, room_name) VALUES ($1, $2, $3)`, setup.id, setup.enabled, setup.name); err != nil { + t.Fatal(err) + } + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/twirp/livekit.RoomService/ListRooms" { + t.Errorf("unexpected operation: %s", r.URL.Path) + } + payload, err := proto.Marshal(&livekit.ListRoomsResponse{Rooms: []*livekit.Room{ + {Sid: "sid-live", Name: "broadcast-generated", NumParticipants: 3, + Metadata: `{"mode":"broadcast","owner_id":"` + owner + `","display_name":"Live show"}`}, + {Name: "unmanaged"}, + }}) + if err != nil { + t.Error(err) + } + w.Header().Set("Content-Type", "application/protobuf") + _, _ = w.Write(payload) + })) + defer server.Close() + original := secrets + defer func() { secrets = original; et.OverrideAuthInfo("", nil) }() + secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret" + et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"}) + result, err := ListRooms(ctx) + if err != nil { + t.Fatal(err) + } + if len(result.Rooms) != 3 { + t.Fatalf("expected one live and two inactive rooms: %+v", result.Rooms) + } + byOwner := make(map[string]*Room) + for _, room := range result.Rooms { + byOwner[room.OwnerID] = room + } + if room := byOwner[owner]; room == nil || !room.Active || room.Name != "broadcast-generated" || room.SID != "sid-live" || room.NumParticipants != 3 { + t.Fatalf("live room must retain its data without duplication: %+v", room) + } + for _, id := range []string{offline, sameTitle} { + if room := byOwner[id]; room == nil || room.Active || room.SID != "" || room.Mode != "broadcast" || room.Name != room.DisplayName { + t.Fatalf("expected inactive configured room: %+v", room) + } + } +} + +func TestMergeBroadcastRoomsMatchesTechnicalName(t *testing.T) { + live := &Room{Active: true, Name: "broadcast-123", OwnerID: "owner", DisplayName: "Title"} + result := mergeBroadcastRooms([]*Room{live}, []*Room{{Name: "broadcast-123", OwnerID: "owner"}}) + if len(result) != 1 || result[0] != live { + t.Fatal("technical room name must not produce a duplicate") + } + if got := mergeBroadcastRooms([]*Room{}, nil); got == nil || len(got) != 0 { + t.Fatal("empty list must stay an empty JSON array") + } +} diff --git a/videochat/tokens.go b/videochat/tokens.go new file mode 100644 index 0000000..6f67279 --- /dev/null +++ b/videochat/tokens.go @@ -0,0 +1,57 @@ +package videochat + +import ( + "context" + + "encore.dev/beta/auth" + "encore.dev/beta/errs" +) + +type TokenParams struct { + RoomName string `json:"room_name"` +} + +type TokenResponse struct { + OwnerID string `json:"owner_id"` + Role string `json:"role"` + ServerURL string `json:"server_url"` + ParticipantToken string `json:"participant_token" encore:"sensitive"` + RoomName string `json:"room_name"` + Identity string `json:"identity"` + ExpiresIn int64 `json:"expires_in"` +} + +// CreateToken grants the authenticated user access to an existing room by name. +// +//encore:api auth method=POST path=/videochat/token +func CreateToken(ctx context.Context, p *TokenParams) (*TokenResponse, error) { + uid, ok := auth.UserID() + if !ok { + return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} + } + if err := validateRoomName(p.RoomName); err != nil { + return nil, err + } + client, serverURL, err := liveKitClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + _, metadata, err := findBroadcast(ctx, client, p.RoomName) + if err != nil { + return nil, err + } + token, err := signJoinToken(secrets.LiveKitAPIKey, secrets.LiveKitAPISecret, string(uid), p.RoomName, metadata.OwnerID == string(uid)) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to generate LiveKit token") + } + role := "viewer" + if metadata.OwnerID == string(uid) { + role = "owner" + } + return &TokenResponse{OwnerID: metadata.OwnerID, Role: role, + ServerURL: serverURL, ParticipantToken: token, RoomName: p.RoomName, + Identity: string(uid), ExpiresIn: int64(tokenTTL.Seconds()), + }, nil +} diff --git a/videochat/videochat.go b/videochat/videochat.go new file mode 100644 index 0000000..86360cc --- /dev/null +++ b/videochat/videochat.go @@ -0,0 +1,15 @@ +// Service videochat provides video chat APIs. +package videochat + +import "context" + +type StatusResponse struct { + Status string `json:"status"` +} + +// Status reports whether the videochat service is available. +// +//encore:api public method=GET path=/videochat/status +func Status(ctx context.Context) (*StatusResponse, error) { + return &StatusResponse{Status: "ok"}, nil +}