feat(voice): вебхуки LiveKit и сторож голосовых состояний
AGENT.md 7.14: - POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе — 503; обработка идемпотентна по event.id (повторная доставка → duplicate); - события: participant_joined восстанавливает состояние после рестарта приложения, participant_left и participant_connection_aborted убирают участника, room_finished очищает комнату целиком, track_published и track_unpublished обновляют флаги камеры и демонстрации экрана; каждое изменение рассылает VOICE_STATE_UPDATE участникам сервера; - сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений дольше шести часов (сбои без вебхука) и уведомляет клиентов; - тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный цикл событий SFU и идемпотентность повторной доставки.
This commit is contained in:
@@ -163,6 +163,8 @@ func run() error {
|
|||||||
srv := server.New(cfg, db, logger, server.Deps{
|
srv := server.New(cfg, db, logger, server.Deps{
|
||||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||||
})
|
})
|
||||||
|
// Сторож голосовых состояний: убирает «призрачные» записи после сбоев.
|
||||||
|
srv.StartVoiceWatchdog(context.Background(), 0)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("http server listening",
|
logger.Info("http server listening",
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"glchat/internal/store"
|
||||||
|
"glchat/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// livekitWebhookEvent — событие вебхука LiveKit (AGENT.md 7.14).
|
||||||
|
type livekitWebhookEvent struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Room struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
} `json:"room"`
|
||||||
|
Participant struct {
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
} `json:"participant"`
|
||||||
|
Track struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookTTL — сколько помним идентификаторы обработанных событий.
|
||||||
|
const webhookTTL = time.Hour
|
||||||
|
|
||||||
|
// registerVoiceWebhook вешает обработчик вебхуков LiveKit (AGENT.md 7.14).
|
||||||
|
func (s *Server) registerVoiceWebhook(router chi.Router) {
|
||||||
|
router.Post("/livekit/webhook", s.handleLiveKitWebhook)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLiveKitWebhook принимает события SFU: подпись проверяется, обработка
|
||||||
|
// идемпотентна по идентификатору события, состояния синхронизируются с БД.
|
||||||
|
func (s *Server) handleLiveKitWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.voice.Enabled() {
|
||||||
|
httpxWriteJSONError(w, http.StatusServiceUnavailable, "voice.disabled", "голос не настроен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.voice.VerifyWebhook(r.Header.Get("Authorization"), body); err != nil {
|
||||||
|
s.logger.WarnContext(r.Context(), "livekit webhook rejected", slog.Any("error", err))
|
||||||
|
httpxWriteJSONError(w, http.StatusUnauthorized, "voice.webhook_unauthorized", "invalid webhook signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var event livekitWebhookEvent
|
||||||
|
if err := json.Unmarshal(body, &event); err != nil {
|
||||||
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.ID != "" && !s.markWebhookProcessed(event.ID) {
|
||||||
|
// Повторная доставка: отвечаем успехом, ничего не меняя.
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true, "duplicate": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.applyLiveKitEvent(r.Context(), event)
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// markWebhookProcessed возвращает false, если событие уже обрабатывалось.
|
||||||
|
func (s *Server) markWebhookProcessed(id string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
s.webhookMu.Lock()
|
||||||
|
defer s.webhookMu.Unlock()
|
||||||
|
if len(s.webhookSeen) > 4096 {
|
||||||
|
cutoff := now.Add(-webhookTTL)
|
||||||
|
for key, at := range s.webhookSeen {
|
||||||
|
if at.Before(cutoff) {
|
||||||
|
delete(s.webhookSeen, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := s.webhookSeen[id]; ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.webhookSeen[id] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLiveKitEvent обновляет голосовые состояния по событию SFU.
|
||||||
|
func (s *Server) applyLiveKitEvent(ctx context.Context, event livekitWebhookEvent) {
|
||||||
|
guildID, channelID, ok := voice.ParseRoomName(event.Room.Name)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch event.Event {
|
||||||
|
case "participant_joined":
|
||||||
|
userID := parseVoiceIdentity(event.Participant.Identity)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Восстанавливаем состояние, если приложение перезапускалось: комната
|
||||||
|
// уже существует, значит участник действительно в голосовой комнате.
|
||||||
|
current, err := s.store.GetVoiceState(ctx, guildID, userID)
|
||||||
|
if err == nil && current.ChannelID == channelID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := store.VoiceStateParams{UserID: userID, GuildID: guildID, ChannelID: channelID}
|
||||||
|
if err == nil {
|
||||||
|
params.SelfMute = current.SelfMute
|
||||||
|
params.SelfDeaf = current.SelfDeaf
|
||||||
|
params.ServerMute = current.ServerMute
|
||||||
|
params.ServerDeaf = current.ServerDeaf
|
||||||
|
}
|
||||||
|
state, err := s.store.UpsertVoiceState(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "livekit webhook: upsert state failed", slog.Any("error", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dispatchVoiceState(ctx, state)
|
||||||
|
|
||||||
|
case "participant_left", "participant_connection_aborted":
|
||||||
|
userID := parseVoiceIdentity(event.Participant.Identity)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteVoiceState(ctx, guildID, userID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dispatchVoiceLeave(ctx, guildID, channelID, userID)
|
||||||
|
|
||||||
|
case "room_finished":
|
||||||
|
states, err := s.store.ListVoiceStates(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, state := range states {
|
||||||
|
if state.ChannelID != channelID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteVoiceState(ctx, guildID, state.UserID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.dispatchVoiceLeave(ctx, guildID, channelID, state.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "track_published", "track_unpublished":
|
||||||
|
userID := parseVoiceIdentity(event.Participant.Identity)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := s.store.GetVoiceState(ctx, guildID, userID)
|
||||||
|
if err != nil || state.ChannelID != channelID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
published := event.Event == "track_published"
|
||||||
|
params := store.VoiceStateParams{
|
||||||
|
UserID: userID, GuildID: guildID, ChannelID: channelID, SessionID: state.SessionID,
|
||||||
|
SelfMute: state.SelfMute, SelfDeaf: state.SelfDeaf,
|
||||||
|
ServerMute: state.ServerMute, ServerDeaf: state.ServerDeaf,
|
||||||
|
Camera: state.Camera, Screen: state.Screen,
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(event.Track.Source) {
|
||||||
|
case "CAMERA":
|
||||||
|
params.Camera = published
|
||||||
|
case "SCREEN_SHARE", "SCREEN_SHARE_AUDIO":
|
||||||
|
params.Screen = published
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := s.store.UpsertVoiceState(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dispatchVoiceState(ctx, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVoiceIdentity превращает identity участника LiveKit в идентификатор.
|
||||||
|
func parseVoiceIdentity(identity string) uint64 {
|
||||||
|
id, err := parseID("identity", identity)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// startVoiceWatchdog периодически убирает «призрачные» состояния: записи без
|
||||||
|
// обновлений дольше шести часов (сервер мог упасть и не получить вебхук).
|
||||||
|
func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 15 * time.Minute
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cleanupStaleVoiceStates(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupStaleVoiceStates удаляет давно не обновлявшиеся голосовые состояния.
|
||||||
|
func (s *Server) cleanupStaleVoiceStates(ctx context.Context) {
|
||||||
|
cutoff := time.Now().UTC().Add(-6 * time.Hour)
|
||||||
|
guilds, err := s.store.ListAllGuilds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removed := 0
|
||||||
|
for _, guild := range guilds {
|
||||||
|
states, err := s.store.ListVoiceStates(ctx, guild.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, state := range states {
|
||||||
|
if state.UpdatedAt.After(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteVoiceState(ctx, guild.ID, state.UserID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
s.dispatchVoiceLeave(ctx, guild.ID, state.ChannelID, state.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
s.logger.InfoContext(ctx, "stale voice states cleaned", slog.Int("removed", removed))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1053,3 +1053,105 @@ func TestVoiceStateFlow(t *testing.T) {
|
|||||||
t.Fatalf("после выхода = %+v", empty.States)
|
t.Fatalf("после выхода = %+v", empty.States)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLiveKitWebhook проверяет подпись, идемпотентность и синхронизацию состояний.
|
||||||
|
func TestLiveKitWebhook(t *testing.T) {
|
||||||
|
f := newMessagingFixture(t)
|
||||||
|
f.srv.cfg.LiveKitAPIKey = "testkey"
|
||||||
|
f.srv.cfg.LiveKitAPISecret = "testsecret"
|
||||||
|
f.srv.cfg.LiveKitURL = "wss://gl.test/rtc"
|
||||||
|
f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour)
|
||||||
|
|
||||||
|
voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
|
||||||
|
`{"name":"Голос","type":"voice"}`, f.ownerCookie)
|
||||||
|
voiceChannel := decodeResponse[struct {
|
||||||
|
Channel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"channel"`
|
||||||
|
}](t, voiceRec)
|
||||||
|
channelID := guildIDOf(t, voiceChannel.Channel.ID)
|
||||||
|
guildID := guildIDOf(t, f.guildID)
|
||||||
|
room := voice.RoomName(guildID, channelID)
|
||||||
|
|
||||||
|
// Событие без подписи отклоняется.
|
||||||
|
noAuth := doJSON(t, f.srv, http.MethodPost, "/api/v1/livekit/webhook",
|
||||||
|
`{"event":"participant_joined"}`, f.ownerCookie)
|
||||||
|
if noAuth.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("webhook без подписи = %d, want 401", noAuth.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := func(body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
signature, err := f.srv.voice.IssueWebhookForTest([]byte(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("подпись вебхука: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/livekit/webhook",
|
||||||
|
strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", signature)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Участник «появился» на стороне SFU — состояние восстанавливается.
|
||||||
|
joined := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` +
|
||||||
|
`"participant":{"identity":"` + f.memberID + `"}}`)
|
||||||
|
if joined.Code != http.StatusOK {
|
||||||
|
t.Fatalf("participant_joined = %d, body = %s", joined.Code, joined.Body.String())
|
||||||
|
}
|
||||||
|
states := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
States []struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
} `json:"voice_states"`
|
||||||
|
}](t, states)
|
||||||
|
if len(list.States) != 1 || list.States[0].UserID != f.memberID {
|
||||||
|
t.Fatalf("состояния после вебхука = %+v", list.States)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторная доставка того же события ничего не меняет.
|
||||||
|
duplicate := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` +
|
||||||
|
`"participant":{"identity":"` + f.memberID + `"}}`)
|
||||||
|
var duplicateBody struct {
|
||||||
|
Duplicate bool `json:"duplicate"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(duplicate.Body.Bytes(), &duplicateBody); err != nil {
|
||||||
|
t.Fatalf("decode duplicate: %v", err)
|
||||||
|
}
|
||||||
|
if !duplicateBody.Duplicate {
|
||||||
|
t.Fatalf("повторное событие должно помечаться duplicate: %s", duplicate.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публикация камеры отмечает флаг.
|
||||||
|
camera := post(`{"event":"track_published","id":"evt-2","room":{"name":"` + room + `"},` +
|
||||||
|
`"participant":{"identity":"` + f.memberID + `"},"track":{"source":"CAMERA"}}`)
|
||||||
|
if camera.Code != http.StatusOK {
|
||||||
|
t.Fatalf("track_published = %d, body = %s", camera.Code, camera.Body.String())
|
||||||
|
}
|
||||||
|
cameraState, err := f.srv.store.GetVoiceState(t.Context(), guildID, guildIDOf(t, f.memberID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVoiceState: %v", err)
|
||||||
|
}
|
||||||
|
if !cameraState.Camera {
|
||||||
|
t.Fatal("флаг камеры не выставлен по вебхуку")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уход участника убирает состояние.
|
||||||
|
left := post(`{"event":"participant_left","id":"evt-3","room":{"name":"` + room + `"},` +
|
||||||
|
`"participant":{"identity":"` + f.memberID + `"}}`)
|
||||||
|
if left.Code != http.StatusOK {
|
||||||
|
t.Fatalf("participant_left = %d", left.Code)
|
||||||
|
}
|
||||||
|
after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie)
|
||||||
|
empty := decodeResponse[struct {
|
||||||
|
States []struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
} `json:"voice_states"`
|
||||||
|
}](t, after)
|
||||||
|
if len(empty.States) != 0 {
|
||||||
|
t.Fatalf("после participant_left = %+v", empty.States)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ type Server struct {
|
|||||||
// presence — время последнего обновления last_seen по пользователю.
|
// presence — время последнего обновления last_seen по пользователю.
|
||||||
presenceMu sync.Mutex
|
presenceMu sync.Mutex
|
||||||
presence map[uint64]time.Time
|
presence map[uint64]time.Time
|
||||||
|
// webhookSeen — идентификаторы обработанных вебхуков LiveKit (идемпотентность).
|
||||||
|
webhookMu sync.Mutex
|
||||||
|
webhookSeen map[string]time.Time
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
http *http.Server
|
http *http.Server
|
||||||
static *staticHandler
|
static *staticHandler
|
||||||
@@ -92,6 +95,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
slowmode: map[string]time.Time{},
|
slowmode: map[string]time.Time{},
|
||||||
presence: map[uint64]time.Time{},
|
presence: map[uint64]time.Time{},
|
||||||
|
webhookSeen: map[string]time.Time{},
|
||||||
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour),
|
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour),
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
@@ -119,6 +123,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerSocialRoutes(s.api)
|
s.registerSocialRoutes(s.api)
|
||||||
s.registerEmojiRoutes(s.api, apiRouter)
|
s.registerEmojiRoutes(s.api, apiRouter)
|
||||||
s.registerVoiceRoutes(s.api)
|
s.registerVoiceRoutes(s.api)
|
||||||
|
s.registerVoiceWebhook(apiRouter)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
})
|
})
|
||||||
@@ -187,6 +192,11 @@ func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() }
|
|||||||
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
|
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
|
||||||
|
|
||||||
|
// StartVoiceWatchdog запускает периодическую очистку голосовых состояний.
|
||||||
|
func (s *Server) StartVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
||||||
|
s.startVoiceWatchdog(ctx, interval)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
|||||||
@@ -119,6 +119,92 @@ func encodeSegment(payload []byte) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(payload)
|
return base64.RawURLEncoding.EncodeToString(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyWebhook проверяет подпись вебхука LiveKit: это JWT, подписанный тем же
|
||||||
|
// API-секретом, а в payload лежит base64(sha256(тело запроса)) (AGENT.md 7.14).
|
||||||
|
func (i *TokenIssuer) VerifyWebhook(authorization string, body []byte) error {
|
||||||
|
if !i.Enabled() {
|
||||||
|
return ErrDisabled
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
||||||
|
if token == "" {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
signingInput := parts[0] + "." + parts[1]
|
||||||
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(i.apiSecret))
|
||||||
|
if _, err := mac.Write([]byte(signingInput)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hmac.Equal(signature, mac.Sum(nil)) {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return errors.New("voice.webhook_unauthorized")
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt > 0 && i.now().UTC().Unix() > claims.ExpiresAt {
|
||||||
|
return errors.New("voice.webhook_expired")
|
||||||
|
}
|
||||||
|
if claims.SHA256 != "" {
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
if subtleCompare(claims.SHA256, base64.StdEncoding.EncodeToString(digest[:])) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// LiveKit может присылать и url-safe вариант кодирования.
|
||||||
|
if subtleCompare(claims.SHA256, base64.RawURLEncoding.EncodeToString(digest[:])) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("voice.webhook_body_mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueWebhookForTest подписывает тело вебхука так же, как это делает LiveKit:
|
||||||
|
// нужен для проверки верификатора и тестов обработчика.
|
||||||
|
func (i *TokenIssuer) IssueWebhookForTest(body []byte) (string, error) {
|
||||||
|
if !i.Enabled() {
|
||||||
|
return "", ErrDisabled
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
claims := map[string]any{
|
||||||
|
"exp": i.now().UTC().Add(time.Minute).Unix(),
|
||||||
|
"sha256": base64.StdEncoding.EncodeToString(digest[:]),
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
|
||||||
|
mac := hmac.New(sha256.New, []byte(i.apiSecret))
|
||||||
|
if _, err := mac.Write([]byte(signingInput)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtleCompare сравнивает строки за постоянное время.
|
||||||
|
func subtleCompare(a, b string) bool {
|
||||||
|
return hmac.Equal([]byte(a), []byte(b))
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
|
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
|
||||||
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
|
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
|
||||||
func ResolveLiveKitURL(raw string) (string, error) {
|
func ResolveLiveKitURL(raw string) (string, error) {
|
||||||
|
|||||||
@@ -89,3 +89,34 @@ func TestResolveLiveKitURL(t *testing.T) {
|
|||||||
t.Fatal("http-адрес должен отклоняться")
|
t.Fatal("http-адрес должен отклоняться")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyWebhookSignature(t *testing.T) {
|
||||||
|
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
||||||
|
body := []byte(`{"event":"participant_joined"}`)
|
||||||
|
|
||||||
|
// Без заголовка — отказ.
|
||||||
|
if err := issuer.VerifyWebhook("", body); err == nil {
|
||||||
|
t.Fatal("подпись обязательна")
|
||||||
|
}
|
||||||
|
// Чужой подписью — отказ.
|
||||||
|
other := voice.NewIssuer("key123", "another-secret", time.Hour)
|
||||||
|
token, err := other.IssueWebhookForTest(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
if err := issuer.VerifyWebhook(token, body); err == nil {
|
||||||
|
t.Fatal("подпись чужим секретом должна отклоняться")
|
||||||
|
}
|
||||||
|
// Своей подписью, но другим телом — отказ.
|
||||||
|
valid, err := issuer.IssueWebhookForTest(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
if err := issuer.VerifyWebhook(valid, []byte(`{"event":"room_finished"}`)); err == nil {
|
||||||
|
t.Fatal("несовпадение тела должно отклоняться")
|
||||||
|
}
|
||||||
|
// Корректная подпись — успех.
|
||||||
|
if err := issuer.VerifyWebhook(valid, body); err != nil {
|
||||||
|
t.Fatalf("VerifyWebhook: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+144
-1
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.103.1",
|
"@tanstack/react-query": "5.103.1",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.4.2",
|
||||||
|
"livekit-client": "^2.22.3",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"react-i18next": "17.0.14",
|
"react-i18next": "17.0.14",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@testing-library/user-event": "14.6.7",
|
"@testing-library/user-event": "14.6.7",
|
||||||
"@types/node": "^26.6.2",
|
"@types/node": "26.6.2",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
@@ -365,6 +366,12 @@
|
|||||||
"specificity": "bin/cli.js"
|
"specificity": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/@cacheable/memory": {
|
"node_modules/@cacheable/memory": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
|
||||||
@@ -815,6 +822,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@livekit/mutex": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/protocol": {
|
||||||
|
"version": "1.50.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||||
|
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.150.0",
|
"version": "0.150.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||||
@@ -1546,6 +1568,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dom-mediacapture-record": {
|
||||||
|
"version": "1.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||||
|
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -2532,6 +2561,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
@@ -2849,6 +2887,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||||
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3243,6 +3290,27 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/livekit-client": {
|
||||||
|
"version": "2.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz",
|
||||||
|
"integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/mutex": "1.1.1",
|
||||||
|
"@livekit/protocol": "1.50.4",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"machina": "^7.0.0",
|
||||||
|
"sdp-transform": "^2.15.0",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typed-emitter": "^2.1.0",
|
||||||
|
"webrtc-adapter": "9.0.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/dom-mediacapture-record": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -3259,6 +3327,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3280,6 +3361,15 @@
|
|||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/machina": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -3720,6 +3810,16 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -3739,6 +3839,21 @@
|
|||||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "2.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -3933,6 +4048,12 @@
|
|||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -3946,6 +4067,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"rxjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
@@ -4518,6 +4648,19 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webrtc-adapter": {
|
||||||
|
"version": "9.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
|
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"sdp": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0",
|
||||||
|
"npm": ">=3.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.103.1",
|
"@tanstack/react-query": "5.103.1",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.4.2",
|
||||||
|
"livekit-client": "^2.22.3",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"react-i18next": "17.0.14",
|
"react-i18next": "17.0.14",
|
||||||
|
|||||||
Reference in New Issue
Block a user