feat(videochat): implement LiveKit integration for video chat functionality
- Add LiveKit client setup and token generation for room access. - Implement room management functions including CreateRoom, OpenRoom, ListRooms, and DeleteRoom. - Introduce participant management with ListParticipants and RemoveParticipant functions. - Create tests for room and participant management, ensuring proper authentication and authorization. - Add error handling for LiveKit API interactions. - Define data structures for room metadata and token responses. - Implement status endpoint for service availability check.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ go.work
|
||||
go.work.sum
|
||||
|
||||
# Environment files
|
||||
/videochat/setup-livekit.txt
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
22
README.md
22
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.
|
||||
|
||||
143
extrahome/data.go
Normal file
143
extrahome/data.go
Normal file
@@ -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
|
||||
}
|
||||
23
extrahome/extrahome.go
Normal file
23
extrahome/extrahome.go
Normal file
@@ -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
|
||||
}
|
||||
7
extrahome/migrations/1_create_siteconfig.up.sql
Normal file
7
extrahome/migrations/1_create_siteconfig.up.sql
Normal file
@@ -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')
|
||||
);
|
||||
79
go.mod
79
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
|
||||
)
|
||||
|
||||
232
go.sum
232
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=
|
||||
|
||||
9
profiles/migrations/11_personal_setup.up.sql
Normal file
9
profiles/migrations/11_personal_setup.up.sql
Normal file
@@ -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) <> '')
|
||||
)
|
||||
);
|
||||
116
profiles/personal_setup.go
Normal file
116
profiles/personal_setup.go
Normal file
@@ -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
|
||||
}
|
||||
117
profiles/personal_setup_test.go
Normal file
117
profiles/personal_setup_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
61
videochat/README.md
Normal file
61
videochat/README.md
Normal file
@@ -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 <session-token>` 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":"<name restituito>"}`.
|
||||
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.
|
||||
145
videochat/livekit.go
Normal file
145
videochat/livekit.go
Normal file
@@ -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()
|
||||
}
|
||||
325
videochat/livekit_test.go
Normal file
325
videochat/livekit_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
79
videochat/open_room.go
Normal file
79
videochat/open_room.go
Normal file
@@ -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
|
||||
}
|
||||
129
videochat/open_room_test.go
Normal file
129
videochat/open_room_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
84
videochat/participants.go
Normal file
84
videochat/participants.go
Normal file
@@ -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)
|
||||
}
|
||||
173
videochat/rooms.go
Normal file
173
videochat/rooms.go
Normal file
@@ -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)
|
||||
}
|
||||
99
videochat/rooms_test.go
Normal file
99
videochat/rooms_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
57
videochat/tokens.go
Normal file
57
videochat/tokens.go
Normal file
@@ -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
|
||||
}
|
||||
15
videochat/videochat.go
Normal file
15
videochat/videochat.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user