mirror of
https://github.com/strukturag/nextcloud-spreed-signaling
synced 2026-03-14 14:35:44 +01:00
1360 lines
34 KiB
Go
1360 lines
34 KiB
Go
/**
|
|
* Standalone signaling server for the Nextcloud Spreed app.
|
|
* Copyright (C) 2017 struktur AG
|
|
*
|
|
* @author Joachim Bauch <bauch@struktur.de>
|
|
*
|
|
* @license GNU AGPL version 3 or any later version
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/pion/ice/v4"
|
|
"github.com/pion/sdp/v3"
|
|
|
|
"github.com/strukturag/nextcloud-spreed-signaling/container"
|
|
"github.com/strukturag/nextcloud-spreed-signaling/geoip"
|
|
"github.com/strukturag/nextcloud-spreed-signaling/internal"
|
|
)
|
|
|
|
const (
|
|
// Version 1.0 validates auth params against the Nextcloud instance.
|
|
HelloVersionV1 = "1.0"
|
|
|
|
// Version 2.0 validates auth params encoded as JWT.
|
|
HelloVersionV2 = "2.0"
|
|
|
|
ActorTypeUsers = "users"
|
|
ActorTypeFederatedUsers = "federated_users"
|
|
)
|
|
|
|
var (
|
|
// InvalidHelloVersion is returned if the version in the "hello" message is not supported.
|
|
InvalidHelloVersion = NewError("invalid_hello_version", "The hello version is not supported.")
|
|
|
|
ErrNoSdp = NewError("no_sdp", "Payload does not contain a SDP.") // +checklocksignore: Global readonly variable.
|
|
ErrInvalidSdp = NewError("invalid_sdp", "Payload does not contain a valid SDP.")
|
|
|
|
ErrNoCandidate = NewError("no_candidate", "Payload does not contain a candidate.")
|
|
ErrInvalidCandidate = NewError("invalid_candidate", "Payload does not contain a valid candidate.")
|
|
|
|
ErrCandidateFiltered = errors.New("candidate was filtered")
|
|
)
|
|
|
|
type PrivateSessionId string
|
|
|
|
type PublicSessionId string
|
|
|
|
type RoomSessionId string
|
|
|
|
const (
|
|
FederatedRoomSessionIdPrefix = "federated|"
|
|
)
|
|
|
|
func (s RoomSessionId) IsFederated() bool {
|
|
return strings.HasPrefix(string(s), FederatedRoomSessionIdPrefix)
|
|
}
|
|
|
|
func (s RoomSessionId) WithoutFederation() RoomSessionId {
|
|
return RoomSessionId(strings.TrimPrefix(string(s), FederatedRoomSessionIdPrefix))
|
|
}
|
|
|
|
type Permission string
|
|
|
|
var (
|
|
PERMISSION_MAY_PUBLISH_MEDIA Permission = "publish-media"
|
|
PERMISSION_MAY_PUBLISH_AUDIO Permission = "publish-audio"
|
|
PERMISSION_MAY_PUBLISH_VIDEO Permission = "publish-video"
|
|
PERMISSION_MAY_PUBLISH_SCREEN Permission = "publish-screen"
|
|
PERMISSION_MAY_CONTROL Permission = "control"
|
|
PERMISSION_TRANSIENT_DATA Permission = "transient-data"
|
|
PERMISSION_HIDE_DISPLAYNAMES Permission = "hide-displaynames"
|
|
|
|
// DefaultPermissionOverrides contains permission overrides for users where
|
|
// no permissions have been set by the server. If a permission is not set in
|
|
// this map, it's assumed the user has that permission.
|
|
DefaultPermissionOverrides = map[Permission]bool{ // +checklocksignore: Global readonly variable.
|
|
PERMISSION_HIDE_DISPLAYNAMES: false,
|
|
}
|
|
)
|
|
|
|
// ClientMessage is a message that is sent from a client to the server.
|
|
type ClientMessage struct {
|
|
json.Marshaler
|
|
json.Unmarshaler
|
|
|
|
// The unique request id (optional).
|
|
Id string `json:"id,omitempty"`
|
|
|
|
// The type of the request.
|
|
Type string `json:"type"`
|
|
|
|
// Filled for type "hello"
|
|
Hello *HelloClientMessage `json:"hello,omitempty"`
|
|
|
|
Bye *ByeClientMessage `json:"bye,omitempty"`
|
|
|
|
Room *RoomClientMessage `json:"room,omitempty"`
|
|
|
|
Message *MessageClientMessage `json:"message,omitempty"`
|
|
|
|
Control *ControlClientMessage `json:"control,omitempty"`
|
|
|
|
Internal *InternalClientMessage `json:"internal,omitempty"`
|
|
|
|
TransientData *TransientDataClientMessage `json:"transient,omitempty"`
|
|
}
|
|
|
|
func (m *ClientMessage) CheckValid() error {
|
|
switch m.Type {
|
|
case "":
|
|
return errors.New("type missing")
|
|
case "hello":
|
|
if m.Hello == nil {
|
|
return errors.New("hello missing")
|
|
} else if err := m.Hello.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "bye":
|
|
// No additional check required.
|
|
case "room":
|
|
if m.Room == nil {
|
|
return errors.New("room missing")
|
|
} else if err := m.Room.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "message":
|
|
if m.Message == nil {
|
|
return errors.New("message missing")
|
|
} else if err := m.Message.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "control":
|
|
if m.Control == nil {
|
|
return errors.New("control missing")
|
|
} else if err := m.Control.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "internal":
|
|
if m.Internal == nil {
|
|
return errors.New("internal missing")
|
|
} else if err := m.Internal.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "transient":
|
|
if m.TransientData == nil {
|
|
return errors.New("transient missing")
|
|
} else if err := m.TransientData.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m ClientMessage) String() string {
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return fmt.Sprintf("Could not serialize %#v: %s", m, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func (m *ClientMessage) NewErrorServerMessage(e *Error) *ServerMessage {
|
|
return &ServerMessage{
|
|
Id: m.Id,
|
|
Type: "error",
|
|
Error: e,
|
|
}
|
|
}
|
|
|
|
func (m *ClientMessage) NewWrappedErrorServerMessage(e error) *ServerMessage {
|
|
if e, ok := e.(*Error); ok {
|
|
return m.NewErrorServerMessage(e)
|
|
}
|
|
|
|
return m.NewErrorServerMessage(NewError("internal_error", e.Error()))
|
|
}
|
|
|
|
// ServerMessage is a message that is sent from the server to a client.
|
|
type ServerMessage struct {
|
|
json.Marshaler
|
|
json.Unmarshaler
|
|
|
|
Id string `json:"id,omitempty"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
Error *Error `json:"error,omitempty"`
|
|
|
|
Welcome *WelcomeServerMessage `json:"welcome,omitempty"`
|
|
|
|
Hello *HelloServerMessage `json:"hello,omitempty"`
|
|
|
|
Bye *ByeServerMessage `json:"bye,omitempty"`
|
|
|
|
Room *RoomServerMessage `json:"room,omitempty"`
|
|
|
|
Message *MessageServerMessage `json:"message,omitempty"`
|
|
|
|
Control *ControlServerMessage `json:"control,omitempty"`
|
|
|
|
Event *EventServerMessage `json:"event,omitempty"`
|
|
|
|
TransientData *TransientDataServerMessage `json:"transient,omitempty"`
|
|
|
|
Internal *InternalServerMessage `json:"internal,omitempty"`
|
|
|
|
Dialout *DialoutInternalClientMessage `json:"dialout,omitempty"`
|
|
}
|
|
|
|
type RoomAware interface {
|
|
IsInRoom(id string) bool
|
|
}
|
|
|
|
func (r *ServerMessage) CloseAfterSend(session RoomAware) bool {
|
|
if r.Type == "bye" {
|
|
return true
|
|
}
|
|
|
|
if r.Type == "event" {
|
|
if evt := r.Event; evt != nil && evt.Target == "roomlist" && evt.Type == "disinvite" {
|
|
// Only close session / connection if the disinvite was for the room
|
|
// the session is currently in.
|
|
if session != nil && evt.Disinvite != nil && session.IsInRoom(evt.Disinvite.RoomId) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (r *ServerMessage) IsChatRefresh() bool {
|
|
if r.Type != "event" || r.Event == nil ||
|
|
r.Event.Type != "message" || r.Event.Message == nil || len(r.Event.Message.Data) == 0 {
|
|
return false
|
|
}
|
|
|
|
data, err := r.Event.Message.GetData()
|
|
if data == nil || err != nil {
|
|
return false
|
|
}
|
|
|
|
if data.Type != "chat" || data.Chat == nil {
|
|
return false
|
|
}
|
|
|
|
return data.Chat.Refresh
|
|
}
|
|
|
|
func (r *ServerMessage) IsParticipantsUpdate() bool {
|
|
if r.Type != "event" || r.Event == nil {
|
|
return false
|
|
}
|
|
if event := r.Event; event.Target != "participants" || event.Type != "update" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *ServerMessage) String() string {
|
|
data, err := json.Marshal(r)
|
|
if err != nil {
|
|
return fmt.Sprintf("Could not serialize %#v: %s", r, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
type Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details json.RawMessage `json:"details,omitempty"`
|
|
}
|
|
|
|
func NewError(code string, message string) *Error {
|
|
return NewErrorDetail(code, message, nil)
|
|
}
|
|
|
|
func NewErrorDetail(code string, message string, details any) *Error {
|
|
var rawDetails json.RawMessage
|
|
if details != nil {
|
|
var err error
|
|
if rawDetails, err = json.Marshal(details); err != nil {
|
|
log.Printf("Could not marshal details %+v for error %s with %s: %s", details, code, message, err)
|
|
return NewError("internal_error", "Could not marshal error details")
|
|
}
|
|
}
|
|
|
|
return &Error{
|
|
Code: code,
|
|
Message: message,
|
|
Details: rawDetails,
|
|
}
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
type WelcomeServerMessage struct {
|
|
Version string `json:"version"`
|
|
Features []string `json:"features,omitempty"`
|
|
Country geoip.Country `json:"country,omitempty"`
|
|
}
|
|
|
|
func NewWelcomeServerMessage(version string, feature ...string) *WelcomeServerMessage {
|
|
message := &WelcomeServerMessage{
|
|
Version: version,
|
|
Features: feature,
|
|
}
|
|
if len(feature) > 0 {
|
|
slices.Sort(message.Features)
|
|
}
|
|
return message
|
|
}
|
|
|
|
func (m *WelcomeServerMessage) AddFeature(feature ...string) {
|
|
newFeatures := slices.Clone(m.Features)
|
|
for _, feat := range feature {
|
|
if !slices.Contains(newFeatures, feat) {
|
|
newFeatures = append(newFeatures, feat)
|
|
}
|
|
}
|
|
slices.Sort(newFeatures)
|
|
m.Features = newFeatures
|
|
}
|
|
|
|
func (m *WelcomeServerMessage) RemoveFeature(feature ...string) {
|
|
newFeatures := make([]string, len(m.Features))
|
|
copy(newFeatures, m.Features)
|
|
for _, feat := range feature {
|
|
idx, found := slices.BinarySearch(newFeatures, feat)
|
|
if found {
|
|
newFeatures = slices.Delete(newFeatures, idx, idx+1)
|
|
}
|
|
}
|
|
m.Features = newFeatures
|
|
}
|
|
|
|
func (m *WelcomeServerMessage) HasFeature(feature string) bool {
|
|
for _, f := range m.Features {
|
|
f = strings.TrimSpace(f)
|
|
if f == feature {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
type ClientType string
|
|
|
|
const (
|
|
HelloClientTypeClient = ClientType("client")
|
|
HelloClientTypeInternal = ClientType("internal")
|
|
HelloClientTypeFederation = ClientType("federation")
|
|
|
|
HelloClientTypeVirtual = ClientType("virtual")
|
|
)
|
|
|
|
type ClientTypeInternalAuthParams struct {
|
|
Random string `json:"random"`
|
|
Token string `json:"token"`
|
|
|
|
Backend string `json:"backend"`
|
|
ParsedBackend *url.URL `json:"-"`
|
|
}
|
|
|
|
func (p *ClientTypeInternalAuthParams) CheckValid() error {
|
|
if p.Backend == "" {
|
|
return errors.New("backend missing")
|
|
}
|
|
|
|
if p.Backend[len(p.Backend)-1] != '/' {
|
|
p.Backend += "/"
|
|
}
|
|
if u, err := url.Parse(p.Backend); err != nil {
|
|
return err
|
|
} else {
|
|
var changed bool
|
|
if u, changed = internal.CanonicalizeUrl(u); changed {
|
|
p.Backend = u.String()
|
|
}
|
|
|
|
p.ParsedBackend = u
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type HelloV2AuthParams struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func (p *HelloV2AuthParams) CheckValid() error {
|
|
if p.Token == "" {
|
|
return errors.New("token missing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type AuthTokenClaims interface {
|
|
jwt.Claims
|
|
|
|
GetUserData() json.RawMessage
|
|
}
|
|
|
|
type HelloV2TokenClaims struct {
|
|
jwt.RegisteredClaims
|
|
|
|
UserData json.RawMessage `json:"userdata,omitempty"`
|
|
}
|
|
|
|
func (c *HelloV2TokenClaims) GetUserData() json.RawMessage {
|
|
return c.UserData
|
|
}
|
|
|
|
type FederationAuthParams struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func (p *FederationAuthParams) CheckValid() error {
|
|
if p.Token == "" {
|
|
return errors.New("token missing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type FederationTokenClaims struct {
|
|
jwt.RegisteredClaims
|
|
|
|
UserData json.RawMessage `json:"userdata,omitempty"`
|
|
}
|
|
|
|
func (c *FederationTokenClaims) GetUserData() json.RawMessage {
|
|
return c.UserData
|
|
}
|
|
|
|
type HelloClientMessageAuth struct {
|
|
// The client type that is connecting. Leave empty to use the default
|
|
// "HelloClientTypeClient"
|
|
Type ClientType `json:"type,omitempty"`
|
|
|
|
Params json.RawMessage `json:"params"`
|
|
|
|
Url string `json:"url"`
|
|
ParsedUrl *url.URL `json:"-"`
|
|
|
|
InternalParams ClientTypeInternalAuthParams `json:"-"`
|
|
HelloV2Params HelloV2AuthParams `json:"-"`
|
|
FederationParams FederationAuthParams `json:"-"`
|
|
}
|
|
|
|
// Type "hello"
|
|
|
|
type HelloClientMessage struct {
|
|
Version string `json:"version"`
|
|
|
|
ResumeId PrivateSessionId `json:"resumeid"`
|
|
|
|
Features []string `json:"features,omitempty"`
|
|
|
|
// The authentication credentials.
|
|
Auth *HelloClientMessageAuth `json:"auth,omitempty"`
|
|
}
|
|
|
|
func (m *HelloClientMessage) CheckValid() error {
|
|
if m.Version != HelloVersionV1 && m.Version != HelloVersionV2 {
|
|
return InvalidHelloVersion
|
|
}
|
|
if m.ResumeId == "" {
|
|
if m.Auth == nil || len(m.Auth.Params) == 0 {
|
|
return errors.New("params missing")
|
|
}
|
|
if m.Auth.Type == "" {
|
|
m.Auth.Type = HelloClientTypeClient
|
|
}
|
|
switch m.Auth.Type {
|
|
case HelloClientTypeClient:
|
|
fallthrough
|
|
case HelloClientTypeFederation:
|
|
if m.Auth.Url == "" {
|
|
return errors.New("url missing")
|
|
}
|
|
|
|
if m.Auth.Url[len(m.Auth.Url)-1] != '/' {
|
|
m.Auth.Url += "/"
|
|
}
|
|
if pos := strings.Index(m.Auth.Url, "ocs/v2.php/apps/spreed/"); pos != -1 {
|
|
m.Auth.Url = m.Auth.Url[:pos]
|
|
}
|
|
|
|
if u, err := url.ParseRequestURI(m.Auth.Url); err != nil {
|
|
return err
|
|
} else {
|
|
var changed bool
|
|
if u, changed = internal.CanonicalizeUrl(u); changed {
|
|
m.Auth.Url = u.String()
|
|
}
|
|
|
|
m.Auth.ParsedUrl = u
|
|
}
|
|
|
|
switch m.Version {
|
|
case HelloVersionV1:
|
|
// No additional validation necessary.
|
|
case HelloVersionV2:
|
|
switch m.Auth.Type {
|
|
case HelloClientTypeClient:
|
|
if err := json.Unmarshal(m.Auth.Params, &m.Auth.HelloV2Params); err != nil {
|
|
return err
|
|
} else if err := m.Auth.HelloV2Params.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case HelloClientTypeFederation:
|
|
if err := json.Unmarshal(m.Auth.Params, &m.Auth.FederationParams); err != nil {
|
|
return err
|
|
} else if err := m.Auth.FederationParams.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
case HelloClientTypeInternal:
|
|
if err := json.Unmarshal(m.Auth.Params, &m.Auth.InternalParams); err != nil {
|
|
return err
|
|
} else if err := m.Auth.InternalParams.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return errors.New("unsupported auth type")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
// Features to send to all clients.
|
|
ServerFeatureMcu = "mcu"
|
|
ServerFeatureSimulcast = "simulcast"
|
|
ServerFeatureUpdateSdp = "update-sdp"
|
|
ServerFeatureAudioVideoPermissions = "audio-video-permissions"
|
|
ServerFeatureTransientData = "transient-data"
|
|
ServerFeatureInCallAll = "incall-all"
|
|
ServerFeatureWelcome = "welcome"
|
|
ServerFeatureHelloV2 = "hello-v2"
|
|
ServerFeatureSwitchTo = "switchto"
|
|
ServerFeatureDialout = "dialout"
|
|
ServerFeatureFederation = "federation"
|
|
ServerFeatureRecipientCall = "recipient-call"
|
|
ServerFeatureJoinFeatures = "join-features"
|
|
ServerFeatureOfferCodecs = "offer-codecs"
|
|
ServerFeatureServerInfo = "serverinfo"
|
|
ServerFeatureChatRelay = "chat-relay"
|
|
ServerFeatureTransientSessionData = "transient-sessiondata"
|
|
|
|
// Features to send to internal clients only.
|
|
ServerFeatureInternalVirtualSessions = "virtual-sessions"
|
|
|
|
// Possible client features from the "hello" request.
|
|
ClientFeatureChatRelay = "chat-relay"
|
|
ClientFeatureInternalInCall = "internal-incall"
|
|
ClientFeatureStartDialout = "start-dialout"
|
|
)
|
|
|
|
var (
|
|
DefaultFeatures = []string{
|
|
ServerFeatureAudioVideoPermissions,
|
|
ServerFeatureTransientData,
|
|
ServerFeatureInCallAll,
|
|
ServerFeatureWelcome,
|
|
ServerFeatureHelloV2,
|
|
ServerFeatureSwitchTo,
|
|
ServerFeatureDialout,
|
|
ServerFeatureFederation,
|
|
ServerFeatureRecipientCall,
|
|
ServerFeatureJoinFeatures,
|
|
ServerFeatureOfferCodecs,
|
|
ServerFeatureServerInfo,
|
|
ServerFeatureChatRelay,
|
|
ServerFeatureTransientSessionData,
|
|
}
|
|
DefaultFeaturesInternal = []string{
|
|
ServerFeatureInternalVirtualSessions,
|
|
ServerFeatureTransientData,
|
|
ServerFeatureInCallAll,
|
|
ServerFeatureWelcome,
|
|
ServerFeatureHelloV2,
|
|
ServerFeatureSwitchTo,
|
|
ServerFeatureDialout,
|
|
ServerFeatureFederation,
|
|
ServerFeatureRecipientCall,
|
|
ServerFeatureJoinFeatures,
|
|
ServerFeatureOfferCodecs,
|
|
ServerFeatureServerInfo,
|
|
ServerFeatureChatRelay,
|
|
ServerFeatureTransientSessionData,
|
|
}
|
|
DefaultWelcomeFeatures = []string{
|
|
ServerFeatureAudioVideoPermissions,
|
|
ServerFeatureInternalVirtualSessions,
|
|
ServerFeatureTransientData,
|
|
ServerFeatureInCallAll,
|
|
ServerFeatureWelcome,
|
|
ServerFeatureHelloV2,
|
|
ServerFeatureSwitchTo,
|
|
ServerFeatureDialout,
|
|
ServerFeatureFederation,
|
|
ServerFeatureRecipientCall,
|
|
ServerFeatureJoinFeatures,
|
|
ServerFeatureOfferCodecs,
|
|
ServerFeatureServerInfo,
|
|
ServerFeatureChatRelay,
|
|
ServerFeatureTransientSessionData,
|
|
}
|
|
)
|
|
|
|
type HelloServerMessage struct {
|
|
Version string `json:"version"`
|
|
|
|
SessionId PublicSessionId `json:"sessionid"`
|
|
ResumeId PrivateSessionId `json:"resumeid"`
|
|
UserId string `json:"userid"`
|
|
|
|
// TODO: Remove once all clients have switched to the "welcome" message.
|
|
Server *WelcomeServerMessage `json:"server,omitempty"`
|
|
}
|
|
|
|
// Type "bye"
|
|
|
|
type ByeClientMessage struct {
|
|
}
|
|
|
|
func (m *ByeClientMessage) CheckValid() error {
|
|
// No additional validation required.
|
|
return nil
|
|
}
|
|
|
|
type ByeServerMessage struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// Type "room"
|
|
|
|
type RoomClientMessage struct {
|
|
RoomId string `json:"roomid"`
|
|
SessionId RoomSessionId `json:"sessionid,omitempty"`
|
|
|
|
Federation *RoomFederationMessage `json:"federation,omitempty"`
|
|
}
|
|
|
|
func (m *RoomClientMessage) CheckValid() error {
|
|
// No additional validation required.
|
|
if m.Federation != nil {
|
|
if err := m.Federation.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type RoomFederationMessage struct {
|
|
SignalingUrl string `json:"signaling"`
|
|
ParsedSignalingUrl *url.URL `json:"-"`
|
|
|
|
NextcloudUrl string `json:"url"`
|
|
ParsedNextcloudUrl *url.URL `json:"-"`
|
|
|
|
RoomId string `json:"roomid,omitempty"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func (m *RoomFederationMessage) CheckValid() error {
|
|
if m.SignalingUrl == "" {
|
|
return errors.New("signaling url missing")
|
|
}
|
|
|
|
if m.SignalingUrl[len(m.SignalingUrl)-1] != '/' {
|
|
m.SignalingUrl += "/"
|
|
}
|
|
if u, err := url.Parse(m.SignalingUrl); err != nil {
|
|
return fmt.Errorf("invalid signaling url: %w", err)
|
|
} else {
|
|
m.ParsedSignalingUrl = u
|
|
}
|
|
if m.NextcloudUrl == "" {
|
|
return errors.New("nextcloud url missing")
|
|
} else if u, err := url.Parse(m.NextcloudUrl); err != nil {
|
|
return fmt.Errorf("invalid nextcloud url: %w", err)
|
|
} else {
|
|
m.ParsedNextcloudUrl = u
|
|
}
|
|
if m.Token == "" {
|
|
return errors.New("token missing")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type RoomServerMessage struct {
|
|
RoomId string `json:"roomid"`
|
|
Properties json.RawMessage `json:"properties,omitempty"`
|
|
Bandwidth *RoomBandwidth `json:"bandwidth,omitempty"`
|
|
}
|
|
|
|
type RoomBandwidth struct {
|
|
MaxStreamBitrate Bandwidth `json:"maxstreambitrate"`
|
|
MaxScreenBitrate Bandwidth `json:"maxscreenbitrate"`
|
|
}
|
|
|
|
type RoomErrorDetails struct {
|
|
Room *RoomServerMessage `json:"room"`
|
|
}
|
|
|
|
// Type "message"
|
|
|
|
const (
|
|
RecipientTypeSession = "session"
|
|
RecipientTypeUser = "user"
|
|
RecipientTypeRoom = "room"
|
|
RecipientTypeCall = "call"
|
|
)
|
|
|
|
type MessageClientMessageRecipient struct {
|
|
Type string `json:"type"`
|
|
|
|
SessionId PublicSessionId `json:"sessionid,omitempty"`
|
|
UserId string `json:"userid,omitempty"`
|
|
}
|
|
|
|
type MessageClientMessage struct {
|
|
Recipient MessageClientMessageRecipient `json:"recipient"`
|
|
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
type MessageClientMessageData struct {
|
|
json.Marshaler
|
|
json.Unmarshaler
|
|
|
|
Type string `json:"type"`
|
|
Sid string `json:"sid"`
|
|
RoomType string `json:"roomType"`
|
|
Payload StringMap `json:"payload"`
|
|
|
|
// Only supported if Type == "offer"
|
|
Bitrate Bandwidth `json:"bitrate,omitempty"`
|
|
AudioCodec string `json:"audiocodec,omitempty"`
|
|
VideoCodec string `json:"videocodec,omitempty"`
|
|
VP9Profile string `json:"vp9profile,omitempty"`
|
|
H264Profile string `json:"h264profile,omitempty"`
|
|
|
|
OfferSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "offer"
|
|
AnswerSdp *sdp.SessionDescription `json:"-"` // Only set if Type == "answer"
|
|
Candidate ice.Candidate `json:"-"` // Only set if Type == "candidate"
|
|
}
|
|
|
|
func (m *MessageClientMessageData) String() string {
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return fmt.Sprintf("Could not serialize %#v: %s", m, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func ParseSDP(s string) (*sdp.SessionDescription, error) {
|
|
var sdp sdp.SessionDescription
|
|
if err := sdp.UnmarshalString(s); err != nil {
|
|
return nil, NewErrorDetail("invalid_sdp", "Error parsing SDP from payload.", StringMap{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
for _, m := range sdp.MediaDescriptions {
|
|
for idx, a := range m.Attributes {
|
|
if !a.IsICECandidate() {
|
|
continue
|
|
}
|
|
|
|
if _, err := ice.UnmarshalCandidate(a.Value); err != nil {
|
|
return nil, NewErrorDetail("invalid_sdp", "Error parsing candidate from media description.", StringMap{
|
|
"media": m.MediaName.Media,
|
|
"idx": idx,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return &sdp, nil
|
|
}
|
|
|
|
var (
|
|
emptyCandidate = &ice.CandidateHost{}
|
|
)
|
|
|
|
// TODO: Use shared method from "mcu_common.go".
|
|
func isValidStreamType(s string) bool {
|
|
switch s {
|
|
case "audio":
|
|
fallthrough
|
|
case "video":
|
|
fallthrough
|
|
case "screen":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m *MessageClientMessageData) CheckValid() error {
|
|
if m.RoomType != "" && !isValidStreamType(m.RoomType) {
|
|
return fmt.Errorf("invalid room type: %s", m.RoomType)
|
|
}
|
|
switch m.Type {
|
|
case "":
|
|
return errors.New("type missing")
|
|
case "offer", "answer":
|
|
sdpText, ok := GetStringMapEntry[string](m.Payload, "sdp")
|
|
if !ok {
|
|
return ErrInvalidSdp
|
|
}
|
|
|
|
sdp, err := ParseSDP(sdpText)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch m.Type {
|
|
case "offer":
|
|
m.OfferSdp = sdp
|
|
case "answer":
|
|
m.AnswerSdp = sdp
|
|
}
|
|
case "candidate":
|
|
candValue, found := m.Payload["candidate"]
|
|
if !found {
|
|
return ErrNoCandidate
|
|
}
|
|
candItem, ok := ConvertStringMap(candValue)
|
|
if !ok {
|
|
return ErrInvalidCandidate
|
|
}
|
|
candText, ok := GetStringMapEntry[string](candItem, "candidate")
|
|
if !ok {
|
|
return ErrInvalidCandidate
|
|
}
|
|
|
|
if candText == "" {
|
|
m.Candidate = emptyCandidate
|
|
} else {
|
|
cand, err := ice.UnmarshalCandidate(candText)
|
|
if err != nil {
|
|
return NewErrorDetail("invalid_candidate", "Error parsing candidate from payload.", StringMap{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
m.Candidate = cand
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func FilterCandidate(c ice.Candidate, allowed *container.IPList, blocked *container.IPList) bool {
|
|
switch c {
|
|
case nil:
|
|
return true
|
|
case emptyCandidate:
|
|
return false
|
|
}
|
|
|
|
ip := net.ParseIP(c.Address())
|
|
if len(ip) == 0 || ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
|
|
// Whitelist has preference.
|
|
if allowed != nil && allowed.Contains(ip) {
|
|
return false
|
|
}
|
|
|
|
// Check if address is blocked manually.
|
|
if blocked != nil && blocked.Contains(ip) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func FilterSDPCandidates(s *sdp.SessionDescription, allowed *container.IPList, blocked *container.IPList) bool {
|
|
modified := false
|
|
for _, m := range s.MediaDescriptions {
|
|
m.Attributes = slices.DeleteFunc(m.Attributes, func(a sdp.Attribute) bool {
|
|
if !a.IsICECandidate() {
|
|
return false
|
|
}
|
|
|
|
if a.Value == "" {
|
|
return false
|
|
}
|
|
|
|
c, err := ice.UnmarshalCandidate(a.Value)
|
|
if err != nil || FilterCandidate(c, allowed, blocked) {
|
|
modified = true
|
|
return true
|
|
}
|
|
|
|
return false
|
|
})
|
|
}
|
|
|
|
return modified
|
|
}
|
|
|
|
func (m *MessageClientMessage) CheckValid() error {
|
|
if len(m.Data) == 0 {
|
|
return errors.New("message empty")
|
|
}
|
|
switch m.Recipient.Type {
|
|
case RecipientTypeRoom:
|
|
fallthrough
|
|
case RecipientTypeCall:
|
|
// No additional checks required.
|
|
case RecipientTypeSession:
|
|
if m.Recipient.SessionId == "" {
|
|
return errors.New("session id missing")
|
|
}
|
|
case RecipientTypeUser:
|
|
if m.Recipient.UserId == "" {
|
|
return errors.New("user id missing")
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported recipient type %v", m.Recipient.Type)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type MessageServerMessageSender struct {
|
|
Type string `json:"type"`
|
|
|
|
SessionId PublicSessionId `json:"sessionid,omitempty"`
|
|
UserId string `json:"userid,omitempty"`
|
|
}
|
|
|
|
type MessageServerMessageData struct {
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type MessageServerMessage struct {
|
|
Sender *MessageServerMessageSender `json:"sender"`
|
|
Recipient *MessageClientMessageRecipient `json:"recipient,omitempty"`
|
|
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
// Type "control"
|
|
|
|
type ControlClientMessage struct {
|
|
MessageClientMessage
|
|
}
|
|
|
|
func (m *ControlClientMessage) CheckValid() error {
|
|
return m.MessageClientMessage.CheckValid()
|
|
}
|
|
|
|
type ControlServerMessage struct {
|
|
Sender *MessageServerMessageSender `json:"sender"`
|
|
Recipient *MessageClientMessageRecipient `json:"recipient,omitempty"`
|
|
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
// Type "internal"
|
|
|
|
type CommonSessionInternalClientMessage struct {
|
|
SessionId PublicSessionId `json:"sessionid"`
|
|
|
|
RoomId string `json:"roomid"`
|
|
}
|
|
|
|
func (m *CommonSessionInternalClientMessage) CheckValid() error {
|
|
if m.SessionId == "" {
|
|
return errors.New("sessionid missing")
|
|
}
|
|
if m.RoomId == "" {
|
|
return errors.New("roomid missing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type AddSessionOptions struct {
|
|
ActorId string `json:"actorId,omitempty"`
|
|
ActorType string `json:"actorType,omitempty"`
|
|
}
|
|
|
|
type AddSessionInternalClientMessage struct {
|
|
CommonSessionInternalClientMessage
|
|
|
|
UserId string `json:"userid,omitempty"`
|
|
User json.RawMessage `json:"user,omitempty"`
|
|
Flags uint32 `json:"flags,omitempty"`
|
|
InCall *int `json:"incall,omitempty"`
|
|
|
|
Options *AddSessionOptions `json:"options,omitempty"`
|
|
}
|
|
|
|
func (m *AddSessionInternalClientMessage) CheckValid() error {
|
|
return m.CommonSessionInternalClientMessage.CheckValid()
|
|
}
|
|
|
|
type UpdateSessionInternalClientMessage struct {
|
|
CommonSessionInternalClientMessage
|
|
|
|
Flags *uint32 `json:"flags,omitempty"`
|
|
InCall *int `json:"incall,omitempty"`
|
|
}
|
|
|
|
func (m *UpdateSessionInternalClientMessage) CheckValid() error {
|
|
return m.CommonSessionInternalClientMessage.CheckValid()
|
|
}
|
|
|
|
type RemoveSessionInternalClientMessage struct {
|
|
CommonSessionInternalClientMessage
|
|
|
|
UserId string `json:"userid,omitempty"`
|
|
}
|
|
|
|
func (m *RemoveSessionInternalClientMessage) CheckValid() error {
|
|
return m.CommonSessionInternalClientMessage.CheckValid()
|
|
}
|
|
|
|
type InCallInternalClientMessage struct {
|
|
InCall int `json:"incall"`
|
|
}
|
|
|
|
func (m *InCallInternalClientMessage) CheckValid() error {
|
|
return nil
|
|
}
|
|
|
|
type DialoutStatus string
|
|
|
|
var (
|
|
DialoutStatusAccepted DialoutStatus = "accepted"
|
|
DialoutStatusRinging DialoutStatus = "ringing"
|
|
DialoutStatusConnected DialoutStatus = "connected"
|
|
DialoutStatusRejected DialoutStatus = "rejected"
|
|
DialoutStatusCleared DialoutStatus = "cleared"
|
|
)
|
|
|
|
type DialoutStatusInternalClientMessage struct {
|
|
CallId string `json:"callid"`
|
|
Status DialoutStatus `json:"status"`
|
|
|
|
// Cause is set if Status is "cleared" or "rejected".
|
|
Cause string `json:"cause,omitempty"`
|
|
Code int `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
type DialoutInternalClientMessage struct {
|
|
Type string `json:"type"`
|
|
|
|
RoomId string `json:"roomid,omitempty"`
|
|
|
|
Error *Error `json:"error,omitempty"`
|
|
|
|
Status *DialoutStatusInternalClientMessage `json:"status,omitempty"`
|
|
}
|
|
|
|
func (m *DialoutInternalClientMessage) CheckValid() error {
|
|
switch m.Type {
|
|
case "":
|
|
return errors.New("type missing")
|
|
case "error":
|
|
if m.Error == nil {
|
|
return errors.New("error missing")
|
|
}
|
|
case "status":
|
|
if m.Status == nil {
|
|
return errors.New("status missing")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type InternalClientMessage struct {
|
|
Type string `json:"type"`
|
|
|
|
AddSession *AddSessionInternalClientMessage `json:"addsession,omitempty"`
|
|
|
|
UpdateSession *UpdateSessionInternalClientMessage `json:"updatesession,omitempty"`
|
|
|
|
RemoveSession *RemoveSessionInternalClientMessage `json:"removesession,omitempty"`
|
|
|
|
InCall *InCallInternalClientMessage `json:"incall,omitempty"`
|
|
|
|
Dialout *DialoutInternalClientMessage `json:"dialout,omitempty"`
|
|
}
|
|
|
|
func (m *InternalClientMessage) CheckValid() error {
|
|
switch m.Type {
|
|
case "":
|
|
return errors.New("type missing")
|
|
case "addsession":
|
|
if m.AddSession == nil {
|
|
return errors.New("addsession missing")
|
|
} else if err := m.AddSession.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "updatesession":
|
|
if m.UpdateSession == nil {
|
|
return errors.New("updatesession missing")
|
|
} else if err := m.UpdateSession.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "removesession":
|
|
if m.RemoveSession == nil {
|
|
return errors.New("removesession missing")
|
|
} else if err := m.RemoveSession.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "incall":
|
|
if m.InCall == nil {
|
|
return errors.New("incall missing")
|
|
} else if err := m.InCall.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
case "dialout":
|
|
if m.Dialout == nil {
|
|
return errors.New("dialout missing")
|
|
} else if err := m.Dialout.CheckValid(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type InternalServerDialoutRequestContents struct {
|
|
// E.164 number to dial (e.g. "+1234567890")
|
|
Number string `json:"number"`
|
|
|
|
Options json.RawMessage `json:"options,omitempty"`
|
|
}
|
|
|
|
type InternalServerDialoutRequest struct {
|
|
RoomId string `json:"roomid"`
|
|
Backend string `json:"backend"`
|
|
|
|
Request *InternalServerDialoutRequestContents `json:"request"`
|
|
}
|
|
|
|
type InternalServerMessage struct {
|
|
Type string `json:"type"`
|
|
|
|
Dialout *InternalServerDialoutRequest `json:"dialout,omitempty"`
|
|
}
|
|
|
|
// Type "event"
|
|
|
|
type RoomEventServerMessage struct {
|
|
RoomId string `json:"roomid"`
|
|
Properties json.RawMessage `json:"properties,omitempty"`
|
|
// TODO(jojo): Change "InCall" to "int" when #914 has landed in NC Talk.
|
|
InCall json.RawMessage `json:"incall,omitempty"`
|
|
Changed []StringMap `json:"changed,omitempty"`
|
|
Users []StringMap `json:"users,omitempty"`
|
|
|
|
All bool `json:"all,omitempty"`
|
|
}
|
|
|
|
func (m *RoomEventServerMessage) String() string {
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return fmt.Sprintf("Could not serialize %#v: %s", m, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
const (
|
|
DisinviteReasonDisinvited = "disinvited"
|
|
DisinviteReasonDeleted = "deleted"
|
|
)
|
|
|
|
type RoomDisinviteEventServerMessage struct {
|
|
RoomEventServerMessage
|
|
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type ChatComment StringMap
|
|
|
|
type RoomEventMessageDataChat struct {
|
|
// Refresh will be included if the client does not support the "chat-relay" feature.
|
|
Refresh bool `json:"refresh,omitempty"`
|
|
|
|
// Comment will be included if the client supports the "chat-relay" feature.
|
|
Comment json.RawMessage `json:"comment,omitempty"`
|
|
// Comments will be included if the client supports the "chat-relay" feature.
|
|
Comments []json.RawMessage `json:"comments,omitempty"`
|
|
}
|
|
|
|
func (m *RoomEventMessageDataChat) HasComment() bool {
|
|
return len(m.Comment) > 0 || slices.ContainsFunc(m.Comments, func(comment json.RawMessage) bool {
|
|
return len(comment) > 0
|
|
})
|
|
}
|
|
|
|
type RoomEventMessageData struct {
|
|
Type string `json:"type"`
|
|
|
|
Chat *RoomEventMessageDataChat `json:"chat,omitempty"`
|
|
}
|
|
|
|
type RoomEventMessage struct {
|
|
RoomId string `json:"roomid"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
func (m *RoomEventMessage) GetData() (*RoomEventMessageData, error) {
|
|
if len(m.Data) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// TODO: Cache parsed result.
|
|
var data RoomEventMessageData
|
|
if err := json.Unmarshal(m.Data, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
type RoomFlagsServerMessage struct {
|
|
RoomId string `json:"roomid"`
|
|
SessionId PublicSessionId `json:"sessionid"`
|
|
Flags uint32 `json:"flags"`
|
|
}
|
|
|
|
type EventServerMessage struct {
|
|
Target string `json:"target"`
|
|
Type string `json:"type"`
|
|
|
|
// Used for target "room"
|
|
Join []EventServerMessageSessionEntry `json:"join,omitempty"`
|
|
Leave []PublicSessionId `json:"leave,omitempty"`
|
|
Change []EventServerMessageSessionEntry `json:"change,omitempty"`
|
|
SwitchTo *EventServerMessageSwitchTo `json:"switchto,omitempty"`
|
|
Resumed *bool `json:"resumed,omitempty"`
|
|
|
|
// Used for target "roomlist" / "participants"
|
|
Invite *RoomEventServerMessage `json:"invite,omitempty"`
|
|
Disinvite *RoomDisinviteEventServerMessage `json:"disinvite,omitempty"`
|
|
Update *RoomEventServerMessage `json:"update,omitempty"`
|
|
Flags *RoomFlagsServerMessage `json:"flags,omitempty"`
|
|
|
|
// Used for target "message"
|
|
Message *RoomEventMessage `json:"message,omitempty"`
|
|
}
|
|
|
|
func (m *EventServerMessage) String() string {
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return fmt.Sprintf("Could not serialize %#v: %s", m, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
type EventServerMessageSessionEntry struct {
|
|
SessionId PublicSessionId `json:"sessionid"`
|
|
UserId string `json:"userid"`
|
|
Features []string `json:"features,omitempty"`
|
|
User json.RawMessage `json:"user,omitempty"`
|
|
RoomSessionId RoomSessionId `json:"roomsessionid,omitempty"`
|
|
Federated bool `json:"federated,omitempty"`
|
|
}
|
|
|
|
func (e EventServerMessageSessionEntry) Clone() EventServerMessageSessionEntry {
|
|
return EventServerMessageSessionEntry{
|
|
SessionId: e.SessionId,
|
|
UserId: e.UserId,
|
|
Features: e.Features,
|
|
User: e.User,
|
|
RoomSessionId: e.RoomSessionId,
|
|
Federated: e.Federated,
|
|
}
|
|
}
|
|
|
|
type EventServerMessageSwitchTo struct {
|
|
RoomId string `json:"roomid"`
|
|
Details json.RawMessage `json:"details,omitempty"`
|
|
}
|
|
|
|
// MCU-related types
|
|
|
|
type AnswerOfferMessage struct {
|
|
To PublicSessionId `json:"to"`
|
|
From PublicSessionId `json:"from"`
|
|
Type string `json:"type"`
|
|
RoomType string `json:"roomType"`
|
|
Payload StringMap `json:"payload"`
|
|
Sid string `json:"sid,omitempty"`
|
|
}
|
|
|
|
// Type "transient"
|
|
|
|
type TransientDataClientMessage struct {
|
|
Type string `json:"type"`
|
|
|
|
Key string `json:"key,omitempty"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
TTL time.Duration `json:"ttl,omitempty"`
|
|
}
|
|
|
|
func (m *TransientDataClientMessage) CheckValid() error {
|
|
switch m.Type {
|
|
case "":
|
|
return errors.New("type missing")
|
|
case "set":
|
|
if m.Key == "" {
|
|
return errors.New("key missing")
|
|
}
|
|
// A "nil" value is allowed and will remove the key.
|
|
case "remove":
|
|
if m.Key == "" {
|
|
return errors.New("key missing")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type TransientDataServerMessage struct {
|
|
Type string `json:"type"`
|
|
|
|
Key string `json:"key,omitempty"`
|
|
OldValue any `json:"oldvalue,omitempty"`
|
|
Value any `json:"value,omitempty"`
|
|
Data StringMap `json:"data,omitempty"`
|
|
}
|