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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user