diff --git a/doc/CHANNELING-API.txt b/doc/CHANNELING-API.txt index 60606947..5a0ac709 100644 --- a/doc/CHANNELING-API.txt +++ b/doc/CHANNELING-API.txt @@ -87,6 +87,16 @@ Sending vs receiving document data encapsulation A : Session attestation token. Only available for incoming data created by other sessions (optional). +Error returns + + Calls providing an Iid which fail will receive an Error document with the + following format: + + { + "Type": "Error", + "Code": "value_identifying_error", + "Message": "A description of the error condition" + } Special purpose documents for channling @@ -147,18 +157,113 @@ Special purpose documents for channling Hello: { Version: "1.0.0", Ua: "Test client 1.0", - Id: "" + Id: "", + "Credentials": {...} } } - Hello document is to be send by the client after connection was - established. + Hello document is to be send by the client after connection was established. + If an Iid is provided, a Welcome document will be returned if joining the + room with the given Id succeeds. Otherwise an Error document with one of the + error codes listed below will be returned. Note that any previous room will + have been left regardless of whether the response is successful. Keys under Hello: - Version : Channel protocol version (string). - Ua : User agent description (string). - Id : Room id. The default Room has the empty string Id ("") (string). + Version : Channel protocol version (string). + Ua : User agent description (string). + Id : Room id. The default Room has the empty string Id ("") (string). + Credentials : An optional RoomCredentials document containing room + authentication information. See the Room document for + information on how such credentials should be handled after + a Welcome is received for the requested room. Note that + providing credentials while joining an existing room which + does not require them is an error, such requests should be + retried without credentials. In contrast, joining a + non-existent room with credentials will create the room + using the given credentials. Note that an error with a code + of authorization_not_required or invalid_credentials shall + cause the client to discard any cached room credentials. + + Error codes: + + default_room_disabled : Joining the room "" is not allowed by this + server. + authorization_required : Joining the given room requires credentials. + authorization_not_required : No credentials should be provided for this + room. + invalid_credentials : The provided credentials are incorrect. + + Welcome + + { + "Type": "Welcome", + "Welcome": { + "Room": {...}, + "Users": [] + } + } + + Welcome is sent in reply to a successful Hello, and contains all data + needed to set up the initial room connection. + + Keys under Welcome: + + Room: Contains the current state of the room, see the description of + the Room document for more details. + Users: Contains the user list for the room, see the description of + the Users document for more details. + + RoomCredentials + + { + "PIN": "my-super-sekrit-code" + } + + RoomCredentials contains room authentication information, and is used as a + child document when joining or updating a room. + + Keys under RoomCredentials: + + PIN : A password string which may be used by clients to authenticate + themselves. Note that acceptable characters for this field may be + constrained by the server based upon its configuration. + + Room + + { + "Type": "Room", + "Name": "room-name-here" + "Credentials": {...} + } + + Clients may send a Room document in order to update all room properties + to the values given in the document. The room name must be given and match + the currently joined room. Successful updates will receive an updated Room + document as a reply, or an Error document if the update fails. + + Addtionally, the Room document is included in responses to initial joins + and broadcast when room properties are updated. + + Keys under Room: + + Name : The human readable ID of the room, currently must be globally + unique. + Credentials : Optional authentication information for the room, see the + documentation of the RoomCredentials document for more + details. This field shall only be present when sending or + receiving an update which alters room authentication data. + It should only be inferred that authentication is not + required if joining a room succeeds without credentials and + no updates containing credentials has been received. + Authentication may be disabled by sending a Room document + containing a RoomCredentials document with only empty + fields. Clients shall discard any cached authentication + information upon receiving such an update. + + Error codes: + + not_in_room : Clients may only update rooms which they have joined. Peer connection documents diff --git a/src/app/spreed-webrtc-server/buffercache.go b/src/app/spreed-webrtc-server/buffercache.go index 75310fc9..44a797e9 100644 --- a/src/app/spreed-webrtc-server/buffercache.go +++ b/src/app/spreed-webrtc-server/buffercache.go @@ -160,3 +160,21 @@ func (cache *bufferCache) New() Buffer { func (cache *bufferCache) Wrap(data []byte) Buffer { return &directBuffer{refcnt: 1, cache: cache, buf: bytes.NewBuffer(data)} } + +func readAll(dest Buffer, r io.Reader) error { + var err error + defer func() { + e := recover() + if e == nil { + return + } + if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge { + err = panicErr + } else { + panic(e) + } + }() + + _, err = dest.ReadFrom(r) + return err +} diff --git a/src/app/spreed-webrtc-server/channeling.go b/src/app/spreed-webrtc-server/channeling.go index 9e040a1a..7d3c516b 100644 --- a/src/app/spreed-webrtc-server/channeling.go +++ b/src/app/spreed-webrtc-server/channeling.go @@ -21,10 +21,37 @@ package main +type DataError struct { + Type string + Code string + Message string +} + +func (err *DataError) Error() string { + return err.Message +} + +type DataRoomCredentials struct { + PIN string +} + type DataHello struct { - Version string - Ua string - Id string + Version string + Ua string + Id string + Credentials *DataRoomCredentials +} + +type DataWelcome struct { + Type string + Room *DataRoom + Users []*DataSession +} + +type DataRoom struct { + Type string + Name string + Credentials *DataRoomCredentials } type DataOffer struct { @@ -159,6 +186,7 @@ type DataIncoming struct { Alive *DataAlive Authentication *DataAuthentication Sessions *DataSessions + Room *DataRoom Iid string `json:",omitempty"` } diff --git a/src/app/spreed-webrtc-server/channelling_api.go b/src/app/spreed-webrtc-server/channelling_api.go new file mode 100644 index 00000000..832a79ae --- /dev/null +++ b/src/app/spreed-webrtc-server/channelling_api.go @@ -0,0 +1,286 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "log" + "strings" + "time" +) + +const ( + maxConferenceSize = 100 +) + +type ChannellingAPI interface { + OnConnect(Client, *Session) + OnIncoming(ResponseSender, *Session, *DataIncoming) + OnDisconnect(*Session) +} + +type channellingAPI struct { + version string + *Config + RoomStatusManager + SessionEncoder + SessionManager + StatsCounter + ContactManager + TurnDataCreator + Unicaster + Broadcaster + buddyImages ImageCache +} + +func NewChannellingAPI(version string, config *Config, roomStatus RoomStatusManager, sessionEncoder SessionEncoder, sessionManager SessionManager, statsCounter StatsCounter, contactManager ContactManager, turnDataCreator TurnDataCreator, unicaster Unicaster, broadcaster Broadcaster, buddyImages ImageCache) ChannellingAPI { + return &channellingAPI{ + version, + config, + roomStatus, + sessionEncoder, + sessionManager, + statsCounter, + contactManager, + turnDataCreator, + unicaster, + broadcaster, + buddyImages, + } +} + +func (api *channellingAPI) OnConnect(client Client, session *Session) { + api.Unicaster.OnConnect(client, session) + api.SendSelf(client, session) +} + +func (api *channellingAPI) OnIncoming(c ResponseSender, session *Session, msg *DataIncoming) { + switch msg.Type { + case "Self": + api.SendSelf(c, session) + case "Hello": + //log.Println("Hello", msg.Hello, c.Index()) + // TODO(longsleep): Filter room id and user agent. + api.UpdateSession(session, &SessionUpdate{Types: []string{"Ua"}, Ua: msg.Hello.Ua}) + if session.Hello && session.Roomid != msg.Hello.Id { + api.LeaveRoom(session) + api.Broadcast(session, session.DataSessionLeft("soft")) + } + + // NOTE(lcooper): Iid filtered for compatibility's sake. + // Evaluate sending unconditionally when supported by all clients. + if room, err := api.JoinRoom(msg.Hello.Id, msg.Hello.Credentials, session, c); err == nil { + session.Hello = true + session.Roomid = msg.Hello.Id + if msg.Iid != "" { + c.Reply(msg.Iid, &DataWelcome{ + Type: "Welcome", + Room: room, + Users: api.RoomUsers(session), + }) + } + api.Broadcast(session, session.DataSessionJoined()) + } else { + session.Hello = false + if msg.Iid != "" { + c.Reply(msg.Iid, err) + } + } + case "Offer": + // TODO(longsleep): Validate offer + api.Unicast(session, msg.Offer.To, msg.Offer) + case "Candidate": + // TODO(longsleep): Validate candidate + api.Unicast(session, msg.Candidate.To, msg.Candidate) + case "Answer": + // TODO(longsleep): Validate Answer + api.Unicast(session, msg.Answer.To, msg.Answer) + case "Users": + if session.Hello { + sessions := &DataSessions{Type: "Users", Users: api.RoomUsers(session)} + c.Reply(msg.Iid, sessions) + } + case "Authentication": + st := msg.Authentication.Authentication + if st == nil { + return + } + + if err := api.Authenticate(session, st, ""); err == nil { + log.Println("Authentication success", session.Userid) + api.SendSelf(c, session) + api.BroadcastSessionStatus(session) + } else { + log.Println("Authentication failed", err, st.Userid, st.Nonce) + } + case "Bye": + api.Unicast(session, msg.Bye.To, msg.Bye) + case "Status": + //log.Println("Status", msg.Status) + api.UpdateSession(session, &SessionUpdate{Types: []string{"Status"}, Status: msg.Status.Status}) + api.BroadcastSessionStatus(session) + case "Chat": + // TODO(longsleep): Limit sent chat messages per incoming connection. + if !msg.Chat.Chat.NoEcho { + api.Unicast(session, session.Id, msg.Chat) + } + msg.Chat.Chat.Time = time.Now().Format(time.RFC3339) + if msg.Chat.To == "" { + // TODO(longsleep): Check if chat broadcast is allowed. + if session.Hello { + api.CountBroadcastChat() + api.Broadcast(session, msg.Chat) + } + } else { + if msg.Chat.Chat.Status != nil && msg.Chat.Chat.Status.ContactRequest != nil { + if err := api.contactrequestHandler(session, msg.Chat.To, msg.Chat.Chat.Status.ContactRequest); err != nil { + log.Println("Ignoring invalid contact request.", err) + return + } + msg.Chat.Chat.Status.ContactRequest.Userid = session.Userid() + } + if msg.Chat.Chat.Status == nil { + api.CountUnicastChat() + } + + api.Unicast(session, msg.Chat.To, msg.Chat) + if msg.Chat.Chat.Mid != "" { + // Send out delivery confirmation status chat message. + api.Unicast(session, session.Id, &DataChat{To: msg.Chat.To, Type: "Chat", Chat: &DataChatMessage{Mid: msg.Chat.Chat.Mid, Status: &DataChatStatus{State: "sent"}}}) + } + } + case "Conference": + // Check conference maximum size. + if len(msg.Conference.Conference) > maxConferenceSize { + log.Println("Refusing to create conference above limit.", len(msg.Conference.Conference)) + } else { + // Send conference update to anyone. + for _, id := range msg.Conference.Conference { + if id != session.Id { + api.Unicast(session, id, msg.Conference) + } + } + } + case "Alive": + c.Reply(msg.Iid, msg.Alive) + case "Sessions": + var users []*DataSession + switch msg.Sessions.Sessions.Type { + case "contact": + if userID, err := api.getContactID(session, msg.Sessions.Sessions.Token); err == nil { + users = api.GetUserSessions(session, userID) + } else { + log.Printf(err.Error()) + } + case "session": + id, err := session.attestation.Decode(msg.Sessions.Sessions.Token) + if err != nil { + log.Printf("Failed to decode incoming attestation", err, msg.Sessions.Sessions.Token) + break + } + session, ok := api.GetSession(id) + if !ok { + log.Printf("Cannot retrieve session for id %s", id) + break + } + users = make([]*DataSession, 1, 1) + users[0] = session.Data() + default: + log.Printf("Unkown incoming sessions request type %s", msg.Sessions.Sessions.Type) + } + + // TODO(lcooper): We ought to reply with a *DataError here if failed. + if users != nil { + c.Reply(msg.Iid, &DataSessions{Type: "Sessions", Users: users, Sessions: msg.Sessions.Sessions}) + } + case "Room": + if room, err := api.UpdateRoom(session, msg.Room); err == nil { + api.Broadcast(session, room) + c.Reply(msg.Iid, room) + } else { + c.Reply(msg.Iid, err) + } + default: + log.Println("OnText unhandled message type", msg.Type) + } +} + +func (api *channellingAPI) OnDisconnect(session *Session) { + dsl := session.DataSessionLeft("hard") + if session.Hello { + api.LeaveRoom(session) + api.Broadcast(session, dsl) + } + + session.RunForAllSubscribers(func(session *Session) { + log.Println("Notifying subscriber that we are gone", session.Id, session.Id) + api.Unicast(session, session.Id, dsl) + }) + + api.Unicaster.OnDisconnect(session) + + api.buddyImages.Delete(session.Id) +} + +func (api *channellingAPI) SendSelf(c Responder, session *Session) { + token, err := api.EncodeSessionToken(session) + if err != nil { + log.Println("Error in OnRegister", err) + return + } + + log.Println("Created new session token", len(token), token) + self := &DataSelf{ + Type: "Self", + Id: session.Id, + Sid: session.Sid, + Userid: session.Userid(), + Suserid: api.EncodeSessionUserID(session), + Token: token, + Version: api.version, + Turn: api.CreateTurnData(session), + Stun: api.StunURIs, + } + c.Reply("", self) +} + +func (api *channellingAPI) UpdateSession(session *Session, s *SessionUpdate) uint64 { + if s.Status != nil { + status, ok := s.Status.(map[string]interface{}) + if ok && status["buddyPicture"] != nil { + pic := status["buddyPicture"].(string) + if strings.HasPrefix(pic, "data:") { + imageId := api.buddyImages.Update(session.Id, pic[5:]) + if imageId != "" { + status["buddyPicture"] = "img:" + imageId + } + } + } + } + + return session.Update(s) +} + +func (api *channellingAPI) BroadcastSessionStatus(session *Session) { + if session.Hello { + api.Broadcast(session, session.DataSessionStatus()) + } +} diff --git a/src/app/spreed-webrtc-server/channelling_api_test.go b/src/app/spreed-webrtc-server/channelling_api_test.go new file mode 100644 index 00000000..c6a5e6dc --- /dev/null +++ b/src/app/spreed-webrtc-server/channelling_api_test.go @@ -0,0 +1,244 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "errors" + "testing" +) + +const ( + testAppVersion string = "0.0.0+unittests" +) + +type fakeClient struct { + replies map[string]interface{} +} + +func (fake *fakeClient) Send(_ Buffer) { +} + +func (fake *fakeClient) Reply(iid string, msg interface{}) { + if fake.replies == nil { + fake.replies = make(map[string]interface{}) + } + + fake.replies[iid] = msg +} + +type fakeRoomManager struct { + joinedRoomID string + leftRoomID string + roomUsers []*DataSession + joinedID string + joinError error + leftID string + broadcasts []interface{} + updatedRoom *DataRoom + updateError error +} + +func (fake *fakeRoomManager) RoomUsers(session *Session) []*DataSession { + return fake.roomUsers +} + +func (fake *fakeRoomManager) JoinRoom(id string, _ *DataRoomCredentials, session *Session, _ Sender) (*DataRoom, error) { + fake.joinedID = id + return &DataRoom{Name: id}, fake.joinError +} + +func (fake *fakeRoomManager) LeaveRoom(session *Session) { + fake.leftID = session.Roomid +} + +func (fake *fakeRoomManager) Broadcast(_ *Session, msg interface{}) { + fake.broadcasts = append(fake.broadcasts, msg) +} + +func (fake *fakeRoomManager) UpdateRoom(_ *Session, _ *DataRoom) (*DataRoom, error) { + return fake.updatedRoom, fake.updateError +} + +func assertReply(t *testing.T, client *fakeClient, iid string) interface{} { + msg, ok := client.replies[iid] + if !ok { + t.Fatalf("No response received for Iid %v", iid) + } + return msg +} + +func assertErrorReply(t *testing.T, client *fakeClient, iid, code string) { + err, ok := assertReply(t, client, iid).(*DataError) + if !ok { + t.Fatalf("Expected response message to be an Error") + } + + if err.Type != "Error" { + t.Error("Message did not have the correct type") + } + + if err.Code != code { + t.Errorf("Expected error code to be %v, but was %v", code, err.Code) + } +} + +func NewTestChannellingAPI() (ChannellingAPI, *fakeClient, *Session, *fakeRoomManager) { + client, roomManager, session := &fakeClient{}, &fakeRoomManager{}, &Session{} + return NewChannellingAPI(testAppVersion, nil, roomManager, nil, nil, nil, nil, nil, nil, roomManager, nil), client, session, roomManager +} + +func Test_ChannellingAPI_OnIncoming_HelloMessage_JoinsTheSelectedRoom(t *testing.T) { + roomID, ua := "foobar", "unit tests" + api, client, session, roomManager := NewTestChannellingAPI() + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{Id: roomID, Ua: ua}}) + + if roomManager.joinedID != roomID { + t.Errorf("Expected to have joined room %v, but got %v", roomID, roomManager.joinedID) + } + + if broadcastCount := len(roomManager.broadcasts); broadcastCount != 1 { + t.Fatalf("Expected 1 broadcast, but got %d", broadcastCount) + } + + dataSession, ok := roomManager.broadcasts[0].(*DataSession) + if !ok { + t.Fatal("Expected a session data broadcast") + } + + if dataSession.Ua != ua { + t.Errorf("Expected to have broadcasted a user agent of %v, but was %v", ua, dataSession.Ua) + } +} + +func Test_ChannellingAPI_OnIncoming_HelloMessage_LeavesAnyPreviouslyJoinedRooms(t *testing.T) { + roomID := "foobar" + api, client, session, roomManager := NewTestChannellingAPI() + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{Id: roomID}}) + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{Id: "baz"}}) + + if roomManager.leftID != roomID { + t.Errorf("Expected to have left room %v, but got %v", roomID, roomManager.leftID) + } + + if broadcastCount := len(roomManager.broadcasts); broadcastCount != 3 { + t.Fatalf("Expected 3 broadcasts, but got %d", broadcastCount) + } + + dataSession, ok := roomManager.broadcasts[1].(*DataSession) + if !ok { + t.Fatal("Expected a session data broadcast") + } + + if status := "soft"; dataSession.Status != status { + t.Errorf("Expected to have broadcast a leave status of of %v, but was %v", status, dataSession.Status) + } +} + +func Test_ChannellingAPI_OnIncoming_HelloMessage_DoesNotJoinIfNotPermitted(t *testing.T) { + api, client, session, roomManager := NewTestChannellingAPI() + roomManager.joinError = errors.New("Can't enter this room") + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{}}) + + if broadcastCount := len(roomManager.broadcasts); broadcastCount != 0 { + t.Fatalf("Expected no broadcasts, but got %d", broadcastCount) + } +} + +func Test_ChannellingAPI_OnIncoming_HelloMessageWithAnIid_RespondsWithAWelcome(t *testing.T) { + iid, roomID := "foo", "a-room" + api, client, session, roomManager := NewTestChannellingAPI() + roomManager.roomUsers = []*DataSession{&DataSession{}} + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: iid, Hello: &DataHello{Id: roomID}}) + + msg, ok := client.replies[iid] + if !ok { + t.Fatalf("No response received for Iid %v", iid) + } + + welcome, ok := msg.(*DataWelcome) + if !ok { + t.Fatalf("Expected response message %#v to be a Welcome", msg) + } + + if welcome.Type != "Welcome" { + t.Error("Message did not have the correct type") + } + + if welcome.Room == nil || welcome.Room.Name != roomID { + t.Errorf("Expected room with name %v, but got %#v", roomID, welcome.Room) + } + + if len(welcome.Users) != len(roomManager.roomUsers) { + t.Errorf("Expected to get users %#v, but was %#v", roomManager.roomUsers, welcome.Users) + } +} + +func Test_ChannellingAPI_OnIncoming_HelloMessageWithAnIid_RespondsWithAnErrorIfTheRoomCannotBeJoined(t *testing.T) { + iid := "foo" + api, client, session, roomManager := NewTestChannellingAPI() + roomManager.joinError = &DataError{Type: "Error", Code: "bad_join"} + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: iid, Hello: &DataHello{}}) + + assertErrorReply(t, client, iid, "bad_join") +} + +func Test_ChannellingAPI_OnIncoming_RoomMessage_RespondsWithAndBroadcastsTheUpdatedRoom(t *testing.T) { + iid, roomName := "123", "foo" + api, client, session, roomManager := NewTestChannellingAPI() + roomManager.updatedRoom = &DataRoom{Name: "FOO"} + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: "0", Hello: &DataHello{Id: roomName}}) + api.OnIncoming(client, session, &DataIncoming{Type: "Room", Iid: iid, Room: &DataRoom{Name: roomName}}) + + room, ok := assertReply(t, client, iid).(*DataRoom) + if !ok { + t.Fatalf("Expected response message to be a Room") + } + + if room.Name != roomManager.updatedRoom.Name { + t.Errorf("Expected updated room with name %v, but got %#v", roomManager.updatedRoom, room) + } + + if broadcastCount := len(roomManager.broadcasts); broadcastCount != 2 { + t.Fatalf("Expected 1 broadcasts, but got %d", broadcastCount) + } + + if _, ok := roomManager.broadcasts[1].(*DataRoom); !ok { + t.Fatal("Expected a room data broadcast") + } +} + +func Test_ChannellingAPI_OnIncoming_RoomMessage_RespondsWithAnErrorIfUpdatingTheRoomFails(t *testing.T) { + iid, roomName := "123", "foo" + api, client, session, roomManager := NewTestChannellingAPI() + roomManager.updateError = &DataError{Type: "Error", Code: "a_room_error", Message: ""} + + api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: "0", Hello: &DataHello{Id: roomName}}) + api.OnIncoming(client, session, &DataIncoming{Type: "Room", Iid: iid, Room: &DataRoom{Name: roomName}}) + + assertErrorReply(t, client, iid, "a_room_error") +} diff --git a/src/app/spreed-webrtc-server/client.go b/src/app/spreed-webrtc-server/client.go new file mode 100644 index 00000000..46a57694 --- /dev/null +++ b/src/app/spreed-webrtc-server/client.go @@ -0,0 +1,87 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "log" +) + +type Sender interface { + Send(Buffer) +} + +type ResponseSender interface { + Sender + Responder +} + +type Responder interface { + Reply(iid string, m interface{}) +} + +type Client interface { + ResponseSender + Session() *Session + Index() uint64 + Close(bool) +} + +type client struct { + Codec + ChannellingAPI + Connection + session *Session +} + +func NewClient(codec Codec, api ChannellingAPI, session *Session) *client { + return &client{codec, api, nil, session} +} + +func (client *client) OnConnect(conn Connection) { + client.Connection = conn + client.ChannellingAPI.OnConnect(client, client.session) +} + +func (client *client) OnText(b Buffer) { + if incoming, err := client.DecodeIncoming(b); err == nil { + client.OnIncoming(client, client.session, incoming) + } else { + log.Println("OnText error while decoding JSON", err) + log.Printf("JSON:\n%s\n", b) + } +} + +func (client *client) OnDisconnect() { + client.ChannellingAPI.OnDisconnect(client.session) +} + +func (client *client) Reply(iid string, m interface{}) { + outgoing := &DataOutgoing{From: client.session.Id, Iid: iid, Data: m} + if b, err := client.EncodeOutgoing(outgoing); err == nil { + client.Send(b) + b.Decref() + } +} + +func (client *client) Session() *Session { + return client.session +} diff --git a/src/app/spreed-webrtc-server/common_test.go b/src/app/spreed-webrtc-server/common_test.go new file mode 100644 index 00000000..395e2a9c --- /dev/null +++ b/src/app/spreed-webrtc-server/common_test.go @@ -0,0 +1,43 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "testing" +) + +func assertDataError(t *testing.T, err error, code string) { + if err == nil { + t.Error("Expected an error, but none was returned") + return + } + + dataError, ok := err.(*DataError) + if !ok { + t.Errorf("Expected error %#v to be a *DataError", err) + return + } + + if code != dataError.Code { + t.Errorf("Expected error code to be %v, but was %v", code, dataError.Code) + } +} diff --git a/src/app/spreed-webrtc-server/connection.go b/src/app/spreed-webrtc-server/connection.go index 137d41ec..e1d343fc 100644 --- a/src/app/spreed-webrtc-server/connection.go +++ b/src/app/spreed-webrtc-server/connection.go @@ -22,14 +22,13 @@ package main import ( - "bytes" "container/list" - "github.com/gorilla/websocket" "io" "log" - "net/http" "sync" "time" + + "github.com/gorilla/websocket" ) const ( @@ -54,110 +53,77 @@ const ( maxRatePerSecond = 20 ) -type Connection struct { +type Connection interface { + Index() uint64 + Send(Buffer) + Close(runCallbacks bool) + readPump() + writePump() +} + +type ConnectionHandler interface { + NewBuffer() Buffer + OnConnect(Connection) + OnText(Buffer) + OnDisconnect() +} + +type connection struct { // References. - h *Hub ws *websocket.Conn - request *http.Request + handler ConnectionHandler // Data handling. condition *sync.Cond queue list.List mutex sync.Mutex isClosed bool - isClosing bool - - // Metadata. - Id string - Roomid string // Keep Roomid here for quick acess without locking c.Session. - Idx uint64 - Session *Session - IsRegistered bool - Hello bool - Version string -} -func NewConnection(h *Hub, ws *websocket.Conn, request *http.Request) *Connection { + // Debugging + Idx uint64 +} - c := &Connection{ - h: h, +func NewConnection(index uint64, ws *websocket.Conn, handler ConnectionHandler) Connection { + c := &connection{ ws: ws, - request: request, + handler: handler, + Idx: index, } c.condition = sync.NewCond(&c.mutex) return c - } -func (c *Connection) close() { +func (c *connection) Index() uint64 { + return c.Idx +} - if !c.isClosed { - c.ws.Close() - c.Session.Close() - c.mutex.Lock() - c.Session = nil - c.isClosed = true - for { - head := c.queue.Front() - if head == nil { - break - } - c.queue.Remove(head) - message := head.Value.(Buffer) - message.Decref() - } - c.condition.Signal() +func (c *connection) Close(runCallbacks bool) { + c.mutex.Lock() + if c.isClosed { c.mutex.Unlock() + return } - -} - -func (c *Connection) register() error { - - s := c.h.CreateSession(c.request, nil) - c.h.registerHandler(c, s) - return nil -} - -func (c *Connection) reregister(token string) error { - - if st, err := c.h.DecodeSessionToken(token); err == nil { - s := c.h.CreateSession(c.request, st) - c.h.registerHandler(c, s) - } else { - log.Println("Error while decoding session token", err) - c.register() + if runCallbacks { + c.handler.OnDisconnect() } - return nil - -} - -func (c *Connection) unregister() { - c.isClosing = true - c.h.unregisterHandler(c) -} - -func (c *Connection) readAll(dest Buffer, r io.Reader) error { - var err error - defer func() { - e := recover() - if e == nil { - return - } - if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge { - err = panicErr - } else { - panic(e) + c.ws.Close() + c.isClosed = true + for { + head := c.queue.Front() + if head == nil { + break } - }() - - _, err = dest.ReadFrom(r) - return err + c.queue.Remove(head) + message := head.Value.(Buffer) + message.Decref() + } + c.condition.Signal() + c.mutex.Unlock() } // readPump pumps messages from the websocket connection to the hub. -func (c *Connection) readPump() { +func (c *connection) readPump() { c.ws.SetReadLimit(maxMessageSize) c.ws.SetReadDeadline(time.Now().Add(pongWait)) c.ws.SetPongHandler(func(string) error { @@ -165,6 +131,10 @@ func (c *Connection) readPump() { return nil }) times := list.New() + + // NOTE(lcooper): This more or less assumes that the write pump is started. + c.handler.OnConnect(c) + for { //fmt.Println("readPump wait nextReader", c.Idx) op, r, err := c.ws.NextReader() @@ -177,12 +147,6 @@ func (c *Connection) readPump() { } switch op { case websocket.TextMessage: - message := c.h.buffers.New() - err = c.readAll(message, r) - if err != nil { - message.Decref() - break - } now := time.Now() if times.Len() == maxRatePerSecond { front := times.Front() @@ -194,18 +158,23 @@ func (c *Connection) readPump() { } } times.PushBack(now) - c.h.server.OnText(c, message) + + message := c.handler.NewBuffer() + err = readAll(message, r) + if err != nil { + message.Decref() + break + } + c.handler.OnText(message) message.Decref() } } - c.unregister() - c.ws.Close() + c.Close(true) } // Write message to outbound queue. -func (c *Connection) send(message Buffer) { - +func (c *connection) Send(message Buffer) { c.mutex.Lock() defer c.mutex.Unlock() if c.isClosed { @@ -223,8 +192,7 @@ func (c *Connection) send(message Buffer) { } // writePump pumps messages from the queue to the websocket connection. -func (c *Connection) writePump() { - +func (c *connection) writePump() { var timer *time.Timer ping := false @@ -301,16 +269,16 @@ func (c *Connection) writePump() { cleanup: //fmt.Println("writePump done") timer.Stop() - c.ws.Close() + c.Close(true) } // Write ping message. -func (c *Connection) ping() error { +func (c *connection) ping() error { return c.write(websocket.PingMessage, []byte{}) } // Write writes a message with the given opCode and payload. -func (c *Connection) write(opCode int, payload []byte) error { +func (c *connection) write(opCode int, payload []byte) error { c.ws.SetWriteDeadline(time.Now().Add(writeWait)) return c.ws.WriteMessage(opCode, payload) } diff --git a/src/app/spreed-webrtc-server/hub.go b/src/app/spreed-webrtc-server/hub.go index f8d75546..9c1d1742 100644 --- a/src/app/spreed-webrtc-server/hub.go +++ b/src/app/spreed-webrtc-server/hub.go @@ -22,21 +22,16 @@ package main import ( - "bytes" "crypto/aes" "crypto/hmac" "crypto/sha1" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "github.com/gorilla/securecookie" "log" - "net/http" - "strings" "sync" - "sync/atomic" "time" ) @@ -46,85 +41,56 @@ const ( maxUsersLength = 5000 ) -// TODO(longsleep): Get rid of MessageRequest type. -type MessageRequest struct { - From string - To string - Message Buffer - Id string +type SessionStore interface { + GetSession(id string) (session *Session, ok bool) } -type HubStat struct { - Rooms int `json:"rooms"` - Connections int `json:"connections"` - Sessions int `json:"sessions"` - Users int `json:"users"` - Count uint64 `json:"count"` - BroadcastChatMessages uint64 `json:"broadcastchatmessages"` - UnicastChatMessages uint64 `json:"unicastchatmessages"` - IdsInRoom map[string][]string `json:"idsinroom,omitempty"` - SessionsById map[string]*DataSession `json:"sessionsbyid,omitempty"` - UsersById map[string]*DataUser `json:"usersbyid,omitempty"` - ConnectionsByIdx map[string]string `json:"connectionsbyidx,omitempty"` +type Unicaster interface { + SessionStore + OnConnect(Client, *Session) + Unicast(session *Session, to string, m interface{}) + OnDisconnect(*Session) } -type Hub struct { - server *Server - connectionTable map[string]*Connection - sessionTable map[string]*Session - roomTable map[string]*RoomWorker - userTable map[string]*User - fakesessionTable map[string]*Session - version string - config *Config - sessionSecret []byte - encryptionSecret []byte - turnSecret []byte - tickets *securecookie.SecureCookie - attestations *securecookie.SecureCookie - count uint64 - mutex sync.RWMutex - buffers BufferCache - broadcastChatMessages uint64 - unicastChatMessages uint64 - buddyImages ImageCache - realm string - tokenName string - useridRetriever func(*http.Request) (string, error) - contacts *securecookie.SecureCookie +type ContactManager interface { + contactrequestHandler(*Session, string, *DataContactRequest) error + getContactID(*Session, string) (string, error) } -func NewHub(version string, config *Config, sessionSecret, encryptionSecret, turnSecret []byte, realm string) *Hub { - - h := &Hub{ - connectionTable: make(map[string]*Connection), - sessionTable: make(map[string]*Session), - roomTable: make(map[string]*RoomWorker), - userTable: make(map[string]*User), - fakesessionTable: make(map[string]*Session), - version: version, - config: config, - sessionSecret: sessionSecret, - encryptionSecret: encryptionSecret, - turnSecret: turnSecret, - realm: realm, - } +type TurnDataCreator interface { + CreateTurnData(*Session) *DataTurn +} + +type ClientStats interface { + ClientInfo(details bool) (int, map[string]*DataSession, map[string]string) +} + +type Hub interface { + ClientStats + Unicaster + TurnDataCreator + ContactManager +} - if len(h.sessionSecret) < 32 { - log.Printf("Weak sessionSecret (only %d bytes). It is recommended to use a key with 32 or 64 bytes.\n", len(h.sessionSecret)) +type hub struct { + OutgoingEncoder + clients map[string]Client + config *Config + turnSecret []byte + mutex sync.RWMutex + contacts *securecookie.SecureCookie +} + +func NewHub(config *Config, sessionSecret, encryptionSecret, turnSecret []byte, encoder OutgoingEncoder) Hub { + + h := &hub{ + OutgoingEncoder: encoder, + clients: make(map[string]Client), + config: config, + turnSecret: turnSecret, } - h.tickets = securecookie.New(h.sessionSecret, h.encryptionSecret) - h.tickets.MaxAge(86400 * 30) // 30 days - h.tickets.HashFunc(sha256.New) - h.tickets.BlockFunc(aes.NewCipher) - h.attestations = securecookie.New(h.sessionSecret, nil) - h.attestations.MaxAge(300) // 5 minutes - h.tickets.HashFunc(sha256.New) - h.buffers = NewBufferCache(1024, bytes.MinRead) - h.buddyImages = NewImageCache() - h.tokenName = fmt.Sprintf("token@%s", h.realm) - h.contacts = securecookie.New(h.sessionSecret, h.encryptionSecret) + h.contacts = securecookie.New(sessionSecret, encryptionSecret) h.contacts.MaxAge(0) // Forever h.contacts.HashFunc(sha256.New) h.contacts.BlockFunc(aes.NewCipher) @@ -132,48 +98,27 @@ func NewHub(version string, config *Config, sessionSecret, encryptionSecret, tur } -func (h *Hub) Stat(details bool) *HubStat { +func (h *hub) ClientInfo(details bool) (clientCount int, sessions map[string]*DataSession, connections map[string]string) { h.mutex.RLock() defer h.mutex.RUnlock() - stat := &HubStat{ - Rooms: len(h.roomTable), - Connections: len(h.connectionTable), - Sessions: len(h.sessionTable), - Users: len(h.userTable), - Count: h.count, - BroadcastChatMessages: atomic.LoadUint64(&h.broadcastChatMessages), - UnicastChatMessages: atomic.LoadUint64(&h.unicastChatMessages), - } + + clientCount = len(h.clients) if details { - rooms := make(map[string][]string) - for roomid, room := range h.roomTable { - sessions := make([]string, 0, len(room.connections)) - for id := range room.connections { - sessions = append(sessions, id) - } - rooms[roomid] = sessions - } - stat.IdsInRoom = rooms - sessions := make(map[string]*DataSession) - for sessionid, session := range h.sessionTable { - sessions[sessionid] = session.Data() - } - stat.SessionsById = sessions - users := make(map[string]*DataUser) - for userid, user := range h.userTable { - users[userid] = user.Data() + sessions = make(map[string]*DataSession) + for id, client := range h.clients { + sessions[id] = client.Session().Data() } - stat.UsersById = users - connections := make(map[string]string) - for id, connection := range h.connectionTable { - connections[fmt.Sprintf("%d", connection.Idx)] = id + + connections = make(map[string]string) + for id, client := range h.clients { + connections[fmt.Sprintf("%d", client.Index())] = id } - stat.ConnectionsByIdx = connections } - return stat + + return } -func (h *Hub) CreateTurnData(id string) *DataTurn { +func (h *hub) CreateTurnData(session *Session) *DataTurn { // Create turn data credentials for shared secret auth with TURN // server. See http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 @@ -182,6 +127,7 @@ func (h *Hub) CreateTurnData(id string) *DataTurn { if len(h.turnSecret) == 0 { return &DataTurn{} } + id := session.Id bar := sha256.New() bar.Write([]byte(id)) id = base64.StdEncoding.EncodeToString(bar.Sum(nil)) @@ -194,389 +140,85 @@ func (h *Hub) CreateTurnData(id string) *DataTurn { } -func (h *Hub) CreateSuserid(session *Session) (suserid string) { - userid := session.Userid() - if userid != "" { - m := hmac.New(sha256.New, h.encryptionSecret) - m.Write([]byte(userid)) - suserid = base64.StdEncoding.EncodeToString(m.Sum(nil)) +func (h *hub) GetSession(id string) (session *Session, ok bool) { + var client Client + client, ok = h.GetClient(id) + if ok { + session = client.Session() } return } -func (h *Hub) CreateSession(request *http.Request, st *SessionToken) *Session { - - var session *Session - var userid string - usersEnabled := h.config.UsersEnabled - - if usersEnabled && h.useridRetriever != nil { - userid, _ = h.useridRetriever(request) - } - - if st == nil { - sid := NewRandomString(32) - id, _ := h.tickets.Encode("id", sid) - session = NewSession(h, id, sid) - log.Println("Created new session id", id) - } else { - if userid == "" { - userid = st.Userid - } - if !usersEnabled { - userid = "" - } - session = NewSession(h, st.Id, st.Sid) - } - - if userid != "" { - h.authenticateHandler(session, st, userid) - } - - return session - -} - -func (h *Hub) CreateFakeSession(userid string) *Session { - - h.mutex.Lock() - session, ok := h.fakesessionTable[userid] - if !ok { - sid := fmt.Sprintf("fake-%s", NewRandomString(27)) - id, _ := h.tickets.Encode("id", sid) - log.Println("Created new fake session id", id) - session = NewSession(h, id, sid) - session.SetUseridFake(userid) - h.fakesessionTable[userid] = session - } - h.mutex.Unlock() - return session - -} - -func (h *Hub) ValidateSession(id, sid string) bool { - - var decoded string - err := h.tickets.Decode("id", id, &decoded) - if err != nil { - log.Println("Session validation error", err, id, sid) - return false - } - if decoded != sid { - log.Println("Session validation failed", id, sid) - return false - } - return true - -} - -func (h *Hub) EncodeSessionToken(st *SessionToken) (string, error) { - - return h.tickets.Encode(h.tokenName, st) - -} - -func (h *Hub) DecodeSessionToken(token string) (*SessionToken, error) { - - st := &SessionToken{} - err := h.tickets.Decode(h.tokenName, token, st) - return st, err - -} - -func (h *Hub) GetRoom(id string) *RoomWorker { - - h.mutex.RLock() - room, ok := h.roomTable[id] - if !ok { - h.mutex.RUnlock() - h.mutex.Lock() - // Need to re-check, another thread might have created the room - // while we waited for the lock. - room, ok = h.roomTable[id] - if !ok { - room = NewRoomWorker(h, id) - h.roomTable[id] = room - h.mutex.Unlock() - go func() { - // Start room, this blocks until room expired. - room.Start() - // Cleanup room when we are done. - h.mutex.Lock() - defer h.mutex.Unlock() - delete(h.roomTable, id) - log.Printf("Cleaned up room '%s'\n", id) - }() - } else { - h.mutex.Unlock() - } - } else { - h.mutex.RUnlock() - } - - return room - -} - -func (h *Hub) GetGlobalConnections() []*Connection { - - if h.config.globalRoomid == "" { - return make([]*Connection, 0) - } - h.mutex.RLock() - if room, ok := h.roomTable[h.config.globalRoomid]; ok { - h.mutex.RUnlock() - return room.GetConnections() - } - - h.mutex.RUnlock() - return make([]*Connection, 0) - -} - -func (h *Hub) RunForAllRooms(f func(room *RoomWorker)) { - - h.mutex.RLock() - for _, room := range h.roomTable { - f(room) - } - h.mutex.RUnlock() - -} - -func (h *Hub) isGlobalRoomid(id string) bool { - - return id != "" && (id == h.config.globalRoomid) - -} - -func (h *Hub) isDefaultRoomid(id string) bool { - - return id == "" -} - -func (h *Hub) registerHandler(c *Connection, s *Session) { - - // Apply session to connection. - c.Id = s.Id - c.Session = s +func (h *hub) OnConnect(client Client, session *Session) { + // Set flags. h.mutex.Lock() - // Set flags. - h.count++ - c.Idx = h.count - c.IsRegistered = true + log.Printf("Created client with id %s", session.Id) // Register connection or replace existing one. - if ec, ok := h.connectionTable[c.Id]; ok { - ec.IsRegistered = false - ec.close() + if ec, ok := h.clients[session.Id]; ok { + ec.Close(false) //log.Printf("Register (%d) from %s: %s (existing)\n", c.Idx, c.Id) } - - h.connectionTable[c.Id] = c - h.sessionTable[c.Id] = s + h.clients[session.Id] = client //fmt.Println("registered", c.Id) h.mutex.Unlock() //log.Printf("Register (%d) from %s: %s\n", c.Idx, c.Id) - h.server.OnRegister(c) - } -func (h *Hub) unregisterHandler(c *Connection) { - +func (h *hub) OnDisconnect(session *Session) { h.mutex.Lock() - if !c.IsRegistered { - h.mutex.Unlock() - return - } - suserid := c.Session.Userid() - delete(h.connectionTable, c.Id) - delete(h.sessionTable, c.Id) - if suserid != "" { - user, ok := h.userTable[suserid] - if ok { - empty := user.RemoveSession(c.Session) - if empty { - delete(h.userTable, suserid) - } - } - } + delete(h.clients, session.Id) h.mutex.Unlock() - h.buddyImages.Delete(c.Id) - //log.Printf("Unregister (%d) from %s: %s\n", c.Idx, c.RemoteAddr, c.Id) - h.server.OnUnregister(c) - c.close() - } -func (h *Hub) unicastHandler(m *MessageRequest) { - +func (h *hub) GetClient(id string) (client Client, ok bool) { h.mutex.RLock() - out, ok := h.connectionTable[m.To] + client, ok = h.clients[id] h.mutex.RUnlock() - if !ok { - log.Println("Unicast To not found", m.To) - return - } - out.send(m.Message) - + return } -func (h *Hub) aliveHandler(c *Connection, alive *DataAlive, iid string) { - - aliveJson := h.buffers.New() - encoder := json.NewEncoder(aliveJson) - err := encoder.Encode(&DataOutgoing{From: c.Id, Data: alive, Iid: iid}) - if err != nil { - log.Println("Alive error while encoding JSON", err) - aliveJson.Decref() - return +func (h *hub) Unicast(session *Session, to string, m interface{}) { + outgoing := &DataOutgoing{ + From: session.Id, + To: to, + A: session.Attestation(), + Data: m, } - c.send(aliveJson) - aliveJson.Decref() - -} - -func (h *Hub) sessionsHandler(c *Connection, srq *DataSessionsRequest, iid string) { - - var users []*DataSession - - switch srq.Type { - case "contact": - contact := &Contact{} - err := h.contacts.Decode("contact", srq.Token, contact) - if err != nil { - log.Println("Failed to decode incoming contact token", err, srq.Token) - return - } - // Use the userid which is not ours from the contact data. - var userid string - suserid := c.Session.Userid() - if contact.A == suserid { - userid = contact.B - } else if contact.B == suserid { - userid = contact.A - } - if userid == "" { - log.Println("Ignoring foreign contact token", contact.A, contact.B) - return - } - // Find foreign user. - h.mutex.RLock() - user, ok := h.userTable[userid] - h.mutex.RUnlock() + if message, err := h.EncodeOutgoing(outgoing); err == nil { + client, ok := h.GetClient(to) if !ok { - // No user. Create fake session. - users = make([]*DataSession, 1, 1) - users[0] = h.CreateFakeSession(userid).Data() - } else { - // Add sessions for forein user. - users = user.SubscribeSessions(c.Session) - } - case "session": - id, err := c.Session.attestation.Decode(srq.Token) - if err != nil { - log.Println("Failed to decode incoming attestation", err, srq.Token) + log.Println("Unicast To not found", to) return } - h.mutex.RLock() - session, ok := h.sessionTable[id] - h.mutex.RUnlock() - if !ok { - return - } - users = make([]*DataSession, 1, 1) - users[0] = session.Data() - default: - log.Println("Unkown incoming sessions request type", srq.Type) + client.Send(message) + message.Decref() } - - if users != nil { - sessions := &DataSessions{Type: "Sessions", Users: users, Sessions: srq} - sessionsJson := h.buffers.New() - encoder := json.NewEncoder(sessionsJson) - err := encoder.Encode(&DataOutgoing{From: c.Id, Data: sessions, Iid: iid}) - if err != nil { - log.Println("Sessions error while encoding JSON", err) - sessionsJson.Decref() - return - } - c.send(sessionsJson) - sessionsJson.Decref() - } - } -func (h *Hub) sessionupdateHandler(s *SessionUpdate) uint64 { - - //fmt.Println("Userupdate", u) - h.mutex.RLock() - session, ok := h.sessionTable[s.Id] - h.mutex.RUnlock() - var rev uint64 - if ok { - if s.Status != nil { - status, ok := s.Status.(map[string]interface{}) - if ok && status["buddyPicture"] != nil { - pic := status["buddyPicture"].(string) - if strings.HasPrefix(pic, "data:") { - imageId := h.buddyImages.Update(s.Id, pic[5:]) - if imageId != "" { - status["buddyPicture"] = "img:" + imageId - } - } - } - } - rev = session.Update(s) - } else { - log.Printf("Update data for unknown user %s\n", s.Id) - } - return rev - -} - -func (h *Hub) sessiontokenHandler(st *SessionToken) (string, error) { - - h.mutex.RLock() - c, ok := h.connectionTable[st.Id] - h.mutex.RUnlock() - - if !ok { - return "", errors.New("no such connection") - } - - nonce, err := c.Session.Authorize(h.realm, st) +func (h *hub) getContactID(session *Session, token string) (userid string, err error) { + contact := &Contact{} + err = h.contacts.Decode("contact", token, contact) if err != nil { - return "", err + err = fmt.Errorf("Failed to decode incoming contact token", err, token) + return } - - return nonce, nil - -} - -func (h *Hub) authenticateHandler(session *Session, st *SessionToken, userid string) error { - - err := session.Authenticate(h.realm, st, userid) - if err == nil { - // Authentication success. - suserid := session.Userid() - h.mutex.Lock() - user, ok := h.userTable[suserid] - if !ok { - user = NewUser(suserid) - h.userTable[suserid] = user - } - h.mutex.Unlock() - user.AddSession(session) + // Use the userid which is not ours from the contact data. + suserid := session.Userid() + if contact.A == suserid { + userid = contact.B + } else if contact.B == suserid { + userid = contact.A } - - return err - + if userid == "" { + err = fmt.Errorf("Ignoring foreign contact token", contact.A, contact.B) + } + return } -func (h *Hub) contactrequestHandler(c *Connection, to string, cr *DataContactRequest) error { +func (h *hub) contactrequestHandler(session *Session, to string, cr *DataContactRequest) error { var err error @@ -588,13 +230,11 @@ func (h *Hub) contactrequestHandler(c *Connection, to string, cr *DataContactReq if err != nil { return err } - suserid := c.Session.Userid() + suserid := session.Userid() if suserid == "" { return errors.New("no userid") } - h.mutex.RLock() - session, ok := h.sessionTable[to] - h.mutex.RUnlock() + session, ok := h.GetSession(to) if !ok { return errors.New("unknown to session for confirm") } @@ -616,13 +256,11 @@ func (h *Hub) contactrequestHandler(c *Connection, to string, cr *DataContactReq } else { // New request. // Create Token with flag and c.Session.Userid and the to Session.Userid. - suserid := c.Session.Userid() + suserid := session.Userid() if suserid == "" { return errors.New("no userid") } - h.mutex.RLock() - session, ok := h.sessionTable[to] - h.mutex.RUnlock() + session, ok := h.GetSession(to) if !ok { return errors.New("unknown to session") } diff --git a/src/app/spreed-webrtc-server/incoming_codec.go b/src/app/spreed-webrtc-server/incoming_codec.go new file mode 100644 index 00000000..dba9c2d5 --- /dev/null +++ b/src/app/spreed-webrtc-server/incoming_codec.go @@ -0,0 +1,69 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "bytes" + "encoding/json" + "log" +) + +type IncomingDecoder interface { + DecodeIncoming(Buffer) (*DataIncoming, error) +} + +type OutgoingEncoder interface { + EncodeOutgoing(*DataOutgoing) (Buffer, error) +} + +type Codec interface { + NewBuffer() Buffer + IncomingDecoder + OutgoingEncoder +} + +type incomingCodec struct { + buffers BufferCache +} + +func NewCodec() Codec { + return &incomingCodec{NewBufferCache(1024, bytes.MinRead)} +} + +func (codec incomingCodec) NewBuffer() Buffer { + return codec.buffers.New() +} + +func (codec incomingCodec) DecodeIncoming(b Buffer) (*DataIncoming, error) { + incoming := &DataIncoming{} + return incoming, json.Unmarshal(b.Bytes(), incoming) +} + +func (codec incomingCodec) EncodeOutgoing(outgoing *DataOutgoing) (Buffer, error) { + b := codec.NewBuffer() + if err := json.NewEncoder(b).Encode(outgoing); err != nil { + log.Println("Error while encoding JSON", err) + b.Decref() + return nil, err + } + return b, nil +} diff --git a/src/app/spreed-webrtc-server/main.go b/src/app/spreed-webrtc-server/main.go index 50db95c8..5bcbffb2 100644 --- a/src/app/spreed-webrtc-server/main.go +++ b/src/app/spreed-webrtc-server/main.go @@ -91,12 +91,12 @@ func roomHandler(w http.ResponseWriter, r *http.Request) { } -func makeImageHandler(hub *Hub, expires time.Duration) http.HandlerFunc { +func makeImageHandler(buddyImages ImageCache, expires time.Duration) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - image := hub.buddyImages.Get(vars["imageid"]) + image := buddyImages.Get(vars["imageid"]) if image == nil { http.Error(w, "Unknown image", http.StatusNotFound) return @@ -223,6 +223,10 @@ func runner(runtime phoenix.Runtime) error { } } + if len(sessionSecret) < 32 { + log.Printf("Weak sessionSecret (only %d bytes). It is recommended to use a key with 32 or 64 bytes.\n", len(sessionSecret)) + } + var encryptionSecret []byte encryptionSecretString, err := runtime.GetString("app", "encryptionSecret") if err != nil { @@ -371,9 +375,6 @@ func runner(runtime phoenix.Runtime) error { // Create realm string from config. computedRealm := fmt.Sprintf("%s.%s", serverRealm, serverToken) - // Create our hub instance. - hub := NewHub(runtimeVersion, config, sessionSecret, encryptionSecret, turnSecret, computedRealm) - // Set number of go routines if it is 1 if goruntime.GOMAXPROCS(0) == 1 { nCPU := goruntime.NumCPU() @@ -426,12 +427,20 @@ func runner(runtime phoenix.Runtime) error { } // Add handlers. + buddyImages := NewImageCache() + codec := NewCodec() + roomManager := NewRoomManager(config, codec) + hub := NewHub(config, sessionSecret, encryptionSecret, turnSecret, codec) + tickets := NewTickets(sessionSecret, encryptionSecret, computedRealm) + sessionManager := NewSessionManager(config, tickets, sessionSecret) + statsManager := NewStatsManager(hub, roomManager, sessionManager) + channellingAPI := NewChannellingAPI(runtimeVersion, config, roomManager, tickets, sessionManager, statsManager, hub, hub, hub, roomManager, buddyImages) r.HandleFunc("/", httputils.MakeGzipHandler(mainHandler)) - r.Handle("/static/img/buddy/{flags}/{imageid}/{idx:.*}", http.StripPrefix(basePath, makeImageHandler(hub, time.Duration(24)*time.Hour))) + r.Handle("/static/img/buddy/{flags}/{imageid}/{idx:.*}", http.StripPrefix(basePath, makeImageHandler(buddyImages, time.Duration(24)*time.Hour))) r.Handle("/static/{path:.*}", http.StripPrefix(basePath, httputils.FileStaticServer(http.Dir(rootFolder)))) r.Handle("/robots.txt", http.StripPrefix(basePath, http.FileServer(http.Dir(path.Join(rootFolder, "static"))))) r.Handle("/favicon.ico", http.StripPrefix(basePath, http.FileServer(http.Dir(path.Join(rootFolder, "static", "img"))))) - r.Handle("/ws", makeWsHubHandler(hub)) + r.Handle("/ws", makeWSHandler(statsManager, sessionManager, codec, channellingAPI)) r.HandleFunc("/{room}", httputils.MakeGzipHandler(roomHandler)) // Add API end points. @@ -442,14 +451,14 @@ func runner(runtime phoenix.Runtime) error { api.AddResourceWithWrapper(&Tokens{tokenProvider}, httputils.MakeGzipHandler, "/tokens") if usersEnabled { // Create Users handler. - users := NewUsers(hub, usersMode, serverRealm, runtime) - api.AddResource(&Sessions{hub: hub, users: users}, "/sessions/{id}/") + users := NewUsers(hub, tickets, sessionManager, usersMode, serverRealm, runtime) + api.AddResource(&Sessions{tickets, hub, users}, "/sessions/{id}/") if usersAllowRegistration { api.AddResource(users, "/users") } } if statsEnabled { - api.AddResourceWithWrapper(&Stats{hub: hub}, httputils.MakeGzipHandler, "/stats") + api.AddResourceWithWrapper(&Stats{statsManager}, httputils.MakeGzipHandler, "/stats") log.Println("Stats are enabled!") } diff --git a/src/app/spreed-webrtc-server/room_manager.go b/src/app/spreed-webrtc-server/room_manager.go new file mode 100644 index 00000000..9efc5420 --- /dev/null +++ b/src/app/spreed-webrtc-server/room_manager.go @@ -0,0 +1,192 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "log" + "sync" +) + +type RoomStatusManager interface { + RoomUsers(*Session) []*DataSession + JoinRoom(string, *DataRoomCredentials, *Session, Sender) (*DataRoom, error) + LeaveRoom(*Session) + UpdateRoom(*Session, *DataRoom) (*DataRoom, error) +} + +type Broadcaster interface { + Broadcast(*Session, interface{}) +} + +type RoomStats interface { + RoomInfo(includeSessions bool) (count int, sessionInfo map[string][]string) +} + +type RoomManager interface { + RoomStatusManager + Broadcaster + RoomStats +} + +type roomManager struct { + sync.RWMutex + OutgoingEncoder + defaultRoomEnabled bool + globalRoomID string + roomTable map[string]RoomWorker +} + +func NewRoomManager(config *Config, encoder OutgoingEncoder) RoomManager { + return &roomManager{ + sync.RWMutex{}, + encoder, + config.DefaultRoomEnabled, + config.globalRoomid, + make(map[string]RoomWorker), + } +} + +func (rooms *roomManager) RoomUsers(session *Session) []*DataSession { + if room, ok := rooms.Get(session.Roomid); ok { + return room.GetUsers() + } + // TODO(lcooper): This should return an error. + return []*DataSession{} +} + +func (rooms *roomManager) JoinRoom(id string, credentials *DataRoomCredentials, session *Session, sender Sender) (*DataRoom, error) { + if id == "" && !rooms.defaultRoomEnabled { + return nil, &DataError{Type: "Error", Code: "default_room_disabled", Message: "The default room is not enabled"} + } + + return rooms.GetOrCreate(id, credentials).Join(credentials, session, sender) +} + +func (rooms *roomManager) LeaveRoom(session *Session) { + if room, ok := rooms.Get(session.Roomid); ok { + room.Leave(session) + } +} + +func (rooms *roomManager) UpdateRoom(session *Session, room *DataRoom) (*DataRoom, error) { + if !session.Hello || session.Roomid != room.Name { + return nil, &DataError{Type: "Error", Code: "not_in_room", Message: "Cannot update other rooms"} + } + // XXX(lcooper): We'll process and send documents without this field + // correctly, however clients cannot not handle it currently. + room.Type = "Room" + if roomWorker, ok := rooms.Get(session.Roomid); ok { + return room, roomWorker.Update(room) + } + // TODO(lcooper): We should almost certainly return an error in this case. + return room, nil +} + +func (rooms *roomManager) Broadcast(session *Session, m interface{}) { + outgoing := &DataOutgoing{ + From: session.Id, + A: session.Attestation(), + Data: m, + } + + message, err := rooms.EncodeOutgoing(outgoing) + if err != nil { + return + } + + id := session.Roomid + if id != "" && id == rooms.globalRoomID { + rooms.RLock() + for _, room := range rooms.roomTable { + room.Broadcast(session, message) + } + rooms.RUnlock() + } else if room, ok := rooms.Get(id); ok { + room.Broadcast(session, message) + } else { + log.Printf("No room named %s found for broadcast message %#v", id, m) + } + message.Decref() +} + +func (rooms *roomManager) RoomInfo(includeSessions bool) (count int, sessionInfo map[string][]string) { + rooms.RLock() + defer rooms.RUnlock() + + count = len(rooms.roomTable) + if includeSessions { + sessionInfo := make(map[string][]string) + for roomid, room := range rooms.roomTable { + sessionInfo[roomid] = room.SessionIDs() + } + } + return +} + +func (rooms *roomManager) Get(id string) (room RoomWorker, ok bool) { + rooms.RLock() + room, ok = rooms.roomTable[id] + rooms.RUnlock() + return +} + +func (rooms *roomManager) GetOrCreate(id string, credentials *DataRoomCredentials) RoomWorker { + room, ok := rooms.Get(id) + if !ok { + rooms.Lock() + // Need to re-check, another thread might have created the room + // while we waited for the lock. + room, ok = rooms.roomTable[id] + if !ok { + room = NewRoomWorker(rooms, id, credentials) + rooms.roomTable[id] = room + rooms.Unlock() + go func() { + // Start room, this blocks until room expired. + room.Start() + // Cleanup room when we are done. + rooms.Lock() + defer rooms.Unlock() + delete(rooms.roomTable, id) + log.Printf("Cleaned up room '%s'\n", id) + }() + } else { + rooms.Unlock() + } + } + + return room +} + +func (rooms *roomManager) GlobalUsers() []*roomUser { + if rooms.globalRoomID == "" { + return make([]*roomUser, 0) + } + rooms.RLock() + if room, ok := rooms.roomTable[rooms.globalRoomID]; ok { + rooms.RUnlock() + return room.Users() + } + + rooms.RUnlock() + return make([]*roomUser, 0) +} diff --git a/src/app/spreed-webrtc-server/room_manager_test.go b/src/app/spreed-webrtc-server/room_manager_test.go new file mode 100644 index 00000000..55ceb2e4 --- /dev/null +++ b/src/app/spreed-webrtc-server/room_manager_test.go @@ -0,0 +1,57 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "testing" +) + +func NewTestRoomManager() RoomManager { + return NewRoomManager(&Config{}, nil) +} + +func Test_RoomManager_UpdateRoom_ReturnsAnErrorIfNoRoomHasBeenJoined(t *testing.T) { + roomManager := NewTestRoomManager() + _, err := roomManager.UpdateRoom(&Session{}, nil) + + assertDataError(t, err, "not_in_room") +} + +func Test_RoomManager_UpdateRoom_ReturnsAnErrorIfUpdatingAnUnjoinedRoom(t *testing.T) { + roomManager := NewTestRoomManager() + session := &Session{Hello: true, Roomid: "foo"} + _, err := roomManager.UpdateRoom(session, &DataRoom{Name: "bar"}) + assertDataError(t, err, "not_in_room") +} + +func Test_RoomManager_UpdateRoom_ReturnsACorrectlyTypedDocument(t *testing.T) { + roomManager := NewTestRoomManager() + session := &Session{Hello: true, Roomid: "foo"} + room, err := roomManager.UpdateRoom(session, &DataRoom{Name: session.Roomid}) + if err != nil { + t.Fatalf("Unexpected error %v updating room", err) + } + + if room.Type != "Room" { + t.Errorf("Expected document type to be Room, but was %v", room.Type) + } +} diff --git a/src/app/spreed-webrtc-server/roomworker.go b/src/app/spreed-webrtc-server/roomworker.go index b671def7..2e52fefa 100644 --- a/src/app/spreed-webrtc-server/roomworker.go +++ b/src/app/spreed-webrtc-server/roomworker.go @@ -22,7 +22,7 @@ package main import ( - "encoding/json" + "crypto/subtle" "log" "sync" "time" @@ -33,39 +33,53 @@ const ( roomExpiryDuration = 60 * time.Second ) -type RoomConnectionUpdate struct { - Id string - Sessionid string - Status bool - Connection *Connection +type RoomWorker interface { + Start() + SessionIDs() []string + Users() []*roomUser + Update(*DataRoom) error + GetUsers() []*DataSession + Broadcast(*Session, Buffer) + Join(*DataRoomCredentials, *Session, Sender) (*DataRoom, error) + Leave(*Session) } -type RoomWorker struct { +type roomWorker struct { // References. - h *Hub + manager *roomManager // Data handling. - workers chan (func()) - expired chan (bool) - connections map[string]*Connection - timer *time.Timer - mutex sync.RWMutex + workers chan (func()) + expired chan (bool) + users map[string]*roomUser + timer *time.Timer + mutex sync.RWMutex // Metadata. - Id string + Id string + credentials *DataRoomCredentials } -func NewRoomWorker(h *Hub, id string) *RoomWorker { +type roomUser struct { + *Session + Sender +} + +func NewRoomWorker(manager *roomManager, id string, credentials *DataRoomCredentials) RoomWorker { log.Printf("Creating worker for room '%s'\n", id) - r := &RoomWorker{ - h: h, - Id: id, + r := &roomWorker{ + manager: manager, + Id: id, + workers: make(chan func(), roomMaxWorkers), + expired: make(chan bool), + users: make(map[string]*roomUser), + } + + if credentials != nil && len(credentials.PIN) > 0 { + r.credentials = credentials } - r.workers = make(chan func(), roomMaxWorkers) - r.expired = make(chan bool) - r.connections = make(map[string]*Connection) // Create expire timer. r.timer = time.AfterFunc(roomExpiryDuration, func() { @@ -76,7 +90,7 @@ func NewRoomWorker(h *Hub, id string) *RoomWorker { } -func (r *RoomWorker) Start() { +func (r *roomWorker) Start() { // Main blocking worker. L: @@ -90,7 +104,7 @@ L: //fmt.Println("Work room expired", r.Id) //fmt.Println("Work room expired", r.Id, len(r.connections)) r.mutex.RLock() - if len(r.connections) == 0 { + if len(r.users) == 0 { // Cleanup room when it is empty. r.mutex.RUnlock() log.Printf("Room worker not in use - cleaning up '%s'\n", r.Id) @@ -107,19 +121,29 @@ L: } -func (r *RoomWorker) GetConnections() []*Connection { +func (r *roomWorker) SessionIDs() []string { + r.mutex.RLock() + defer r.mutex.RUnlock() + sessions := make([]string, 0, len(r.users)) + for id := range r.users { + sessions = append(sessions, id) + } + return sessions +} + +func (r *roomWorker) Users() []*roomUser { r.mutex.RLock() defer r.mutex.RUnlock() - connections := make([]*Connection, 0, len(r.connections)) - for _, connection := range r.connections { - connections = append(connections, connection) + users := make([]*roomUser, 0, len(r.users)) + for _, user := range r.users { + users = append(users, user) } - return connections + return users } -func (r *RoomWorker) Run(f func()) bool { +func (r *roomWorker) Run(f func()) bool { select { case r.workers <- f: @@ -131,13 +155,30 @@ func (r *RoomWorker) Run(f func()) bool { } -func (r *RoomWorker) usersHandler(c *Connection) { +func (r *roomWorker) Update(room *DataRoom) error { + fault := make(chan error, 1) + worker := func() { + r.mutex.Lock() + if room.Credentials != nil { + if len(room.Credentials.PIN) > 0 { + r.credentials = room.Credentials + } else { + r.credentials = nil + } + } + r.mutex.Unlock() + fault <- nil + } + r.Run(worker) + return <-fault +} +func (r *roomWorker) GetUsers() []*DataSession { + out := make(chan []*DataSession, 1) worker := func() { - sessions := &DataSessions{Type: "Users"} var sl []*DataSession - appender := func(ec *Connection) bool { - ecsession := ec.Session + appender := func(user *roomUser) bool { + ecsession := user.Session if ecsession != nil { session := ecsession.Data() session.Type = "Online" @@ -150,73 +191,95 @@ func (r *RoomWorker) usersHandler(c *Connection) { return true } r.mutex.RLock() - sl = make([]*DataSession, 0, len(r.connections)) + sl = make([]*DataSession, 0, len(r.users)) // Include connections in this room. - for _, ec := range r.connections { - if !appender(ec) { + for _, user := range r.users { + if !appender(user) { break } } r.mutex.RUnlock() // Include connections to global room. - for _, ec := range c.h.GetGlobalConnections() { + for _, ec := range r.manager.GlobalUsers() { if !appender(ec) { break } } - sessions.Users = sl - sessionsJson := c.h.buffers.New() - encoder := json.NewEncoder(sessionsJson) - err := encoder.Encode(&DataOutgoing{From: c.Id, Data: sessions}) - if err != nil { - log.Println("Users error while encoding JSON", err) - sessionsJson.Decref() - return - } - c.send(sessionsJson) - sessionsJson.Decref() + out <- sl } r.Run(worker) - + return <-out } -func (r *RoomWorker) broadcastHandler(m *MessageRequest) { +func (r *roomWorker) Broadcast(session *Session, message Buffer) { worker := func() { r.mutex.RLock() defer r.mutex.RUnlock() - for id, ec := range r.connections { - if id == m.From { + for id, user := range r.users { + if id == session.Id { // Skip broadcast to self. continue } //fmt.Printf("%s\n", m.Message) - ec.send(m.Message) + user.Send(message) } - m.Message.Decref() + message.Decref() } - m.Message.Incref() + message.Incref() r.Run(worker) } -func (r *RoomWorker) connectionHandler(rcu *RoomConnectionUpdate) { +type joinResult struct { + *DataRoom + error +} +func (r *roomWorker) Join(credentials *DataRoomCredentials, session *Session, sender Sender) (*DataRoom, error) { + results := make(chan joinResult, 1) worker := func() { r.mutex.Lock() - defer r.mutex.Unlock() - if rcu.Status { - r.connections[rcu.Sessionid] = rcu.Connection - } else { - if _, ok := r.connections[rcu.Sessionid]; ok { - delete(r.connections, rcu.Sessionid) + if r.credentials == nil && credentials != nil { + results <- joinResult{nil, &DataError{"Error", "authorization_not_required", "No credentials may be provided for this room"}} + r.mutex.Unlock() + return + } else if r.credentials != nil { + if credentials == nil { + results <- joinResult{nil, &DataError{"Error", "authorization_required", "Valid credentials are required to join this room"}} + r.mutex.Unlock() + return + } + + if len(r.credentials.PIN) != len(credentials.PIN) || subtle.ConstantTimeCompare([]byte(r.credentials.PIN), []byte(credentials.PIN)) != 1 { + results <- joinResult{nil, &DataError{"Error", "invalid_credentials", "The provided credentials are incorrect"}} + r.mutex.Unlock() + return } } - } + r.users[session.Id] = &roomUser{session, sender} + // NOTE(lcooper): Needs to be a copy, else we risk races with + // a subsequent modification of room properties. + result := joinResult{&DataRoom{Name: r.Id}, nil} + r.mutex.Unlock() + results <- result + } r.Run(worker) + result := <-results + return result.DataRoom, result.error +} +func (r *roomWorker) Leave(session *Session) { + worker := func() { + r.mutex.Lock() + defer r.mutex.Unlock() + if _, ok := r.users[session.Id]; ok { + delete(r.users, session.Id) + } + } + r.Run(worker) } diff --git a/src/app/spreed-webrtc-server/roomworker_test.go b/src/app/spreed-webrtc-server/roomworker_test.go new file mode 100644 index 00000000..ad785440 --- /dev/null +++ b/src/app/spreed-webrtc-server/roomworker_test.go @@ -0,0 +1,124 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "testing" +) + +const ( + testRoomName string = "a-room-name" +) + +func NewTestRoomWorker() RoomWorker { + worker := NewRoomWorker(&roomManager{}, testRoomName, nil) + go worker.Start() + return worker +} + +func NewTestRoomWorkerWithPIN(t *testing.T) (RoomWorker, string) { + pin := "asdf" + worker := NewRoomWorker(&roomManager{}, testRoomName, &DataRoomCredentials{PIN: pin}) + go worker.Start() + return worker, pin +} + +func Test_RoomWorker_Join_SucceedsWhenNoCredentialsAreRequired(t *testing.T) { + worker := NewTestRoomWorker() + + _, err := worker.Join(nil, &Session{}, nil) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + if userCount := len(worker.GetUsers()); userCount != 1 { + t.Errorf("Expected join to have been accepted but room contains %d users", userCount) + } +} + +func Test_RoomWorker_Join_FailsIfCredentialsAreGivenWhenUnneeded(t *testing.T) { + worker := NewTestRoomWorker() + + _, err := worker.Join(&DataRoomCredentials{}, &Session{}, nil) + + assertDataError(t, err, "authorization_not_required") + if userCount := len(worker.GetUsers()); userCount != 0 { + t.Errorf("Expected join to have been rejected but room contains %d users", userCount) + } +} + +func Test_RoomWorker_Join_FailsIfNoCredentialsAreGiven(t *testing.T) { + worker, _ := NewTestRoomWorkerWithPIN(t) + + _, err := worker.Join(nil, &Session{}, nil) + + assertDataError(t, err, "authorization_required") + if userCount := len(worker.GetUsers()); userCount != 0 { + t.Errorf("Expected join to have been rejected but room contains %d users", userCount) + } +} + +func Test_RoomWorker_Join_FailsIfIncorrectCredentialsAreGiven(t *testing.T) { + worker, _ := NewTestRoomWorkerWithPIN(t) + + _, err := worker.Join(&DataRoomCredentials{PIN: "adfs"}, &Session{}, nil) + + assertDataError(t, err, "invalid_credentials") + if userCount := len(worker.GetUsers()); userCount != 0 { + t.Errorf("Expected join to have been rejected but room contains %d users", userCount) + } +} + +func Test_RoomWorker_Join_SucceedsWhenTheCorrectPINIsGiven(t *testing.T) { + worker, pin := NewTestRoomWorkerWithPIN(t) + + if _, err := worker.Join(&DataRoomCredentials{PIN: pin}, &Session{}, nil); err != nil { + t.Fatalf("Unexpected error %v", err) + } + + if len(worker.GetUsers()) < 1 { + t.Error("Expected join to have been accepted but room contains no users") + } +} + +func Test_RoomWorker_Update_AllowsClearingCredentials(t *testing.T) { + worker, _ := NewTestRoomWorkerWithPIN(t) + + if err := worker.Update(&DataRoom{Credentials: &DataRoomCredentials{PIN: ""}}); err != nil { + t.Fatalf("Failed to update room: %v", err) + } + + _, err := worker.Join(&DataRoomCredentials{}, &Session{}, nil) + assertDataError(t, err, "authorization_not_required") +} + +func Test_RoomWorker_Update_RetainsCredentialsWhenOtherPropertiesAreUpdated(t *testing.T) { + worker, pin := NewTestRoomWorkerWithPIN(t) + + if err := worker.Update(&DataRoom{}); err != nil { + t.Fatalf("Failed to update room: %v", err) + } + + if _, err := worker.Join(&DataRoomCredentials{PIN: pin}, &Session{}, nil); err != nil { + t.Fatalf("Unexpected error joining room %v", err) + } +} diff --git a/src/app/spreed-webrtc-server/server.go b/src/app/spreed-webrtc-server/server.go deleted file mode 100644 index 9ea70ad2..00000000 --- a/src/app/spreed-webrtc-server/server.go +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Spreed WebRTC. - * Copyright (C) 2013-2014 struktur AG - * - * This file is part of Spreed WebRTC. - * - * 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 . - * - */ - -package main - -import ( - "encoding/json" - "log" - "sync/atomic" - "time" -) - -const ( - maxConferenceSize = 100 -) - -type Server struct { -} - -func (s *Server) OnRegister(c *Connection) { - //log.Println("OnRegister", c.id) - if token, err := c.h.EncodeSessionToken(c.Session.Token()); err == nil { - log.Println("Created new session token", len(token), token) - // Send stuff back. - s.Unicast(c, c.Id, &DataSelf{ - Type: "Self", - Id: c.Id, - Sid: c.Session.Sid, - Userid: c.Session.Userid(), - Suserid: c.h.CreateSuserid(c.Session), - Token: token, - Version: c.h.version, - Turn: c.h.CreateTurnData(c.Id), - Stun: c.h.config.StunURIs, - }) - } else { - log.Println("Error in OnRegister", c.Idx, err) - } -} - -func (s *Server) OnUnregister(c *Connection) { - //log.Println("OnUnregister", c.id) - dsl := c.Session.DataSessionLeft("hard") - if c.Hello { - s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid}) - s.Broadcast(c, dsl) - } - c.Session.RunForAllSubscribers(func(session *Session) { - log.Println("Notifying subscriber that we are gone", c.Id, session.Id) - s.Unicast(c, session.Id, dsl) - }) -} - -func (s *Server) OnText(c *Connection, b Buffer) { - - //log.Printf("OnText from %d: %s\n", c.Id, b) - var msg DataIncoming - err := json.Unmarshal(b.Bytes(), &msg) - if err != nil { - log.Println("OnText error while decoding JSON", err) - log.Printf("JSON:\n%s\n", b) - return - } - - switch msg.Type { - case "Self": - s.OnRegister(c) - case "Hello": - //log.Println("Hello", msg.Hello, c.Idx) - // TODO(longsleep): Filter room id and user agent. - s.UpdateSession(c, &SessionUpdate{Types: []string{"Roomid", "Ua"}, Roomid: msg.Hello.Id, Ua: msg.Hello.Ua}) - if c.Hello && c.Roomid != msg.Hello.Id { - // Room changed. - s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid}) - s.Broadcast(c, c.Session.DataSessionLeft("soft")) - } - c.Roomid = msg.Hello.Id - if c.h.config.DefaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { - c.Hello = true - s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid, Status: true}) - s.Broadcast(c, c.Session.DataSessionJoined()) - } else { - c.Hello = false - } - case "Offer": - // TODO(longsleep): Validate offer - s.Unicast(c, msg.Offer.To, msg.Offer) - case "Candidate": - // TODO(longsleep): Validate candidate - s.Unicast(c, msg.Candidate.To, msg.Candidate) - case "Answer": - // TODO(longsleep): Validate Answer - s.Unicast(c, msg.Answer.To, msg.Answer) - case "Users": - if c.Hello { - s.Users(c) - } - case "Authentication": - if msg.Authentication.Authentication != nil && s.Authenticate(c, msg.Authentication.Authentication) { - s.OnRegister(c) - if c.Hello { - s.Broadcast(c, c.Session.DataSessionStatus()) - } - } - case "Bye": - s.Unicast(c, msg.Bye.To, msg.Bye) - case "Status": - //log.Println("Status", msg.Status) - s.UpdateSession(c, &SessionUpdate{Types: []string{"Status"}, Status: msg.Status.Status}) - if c.Hello { - s.Broadcast(c, c.Session.DataSessionStatus()) - } - case "Chat": - // TODO(longsleep): Limit sent chat messages per incoming connection. - if !msg.Chat.Chat.NoEcho { - s.Unicast(c, c.Id, msg.Chat) - } - msg.Chat.Chat.Time = time.Now().Format(time.RFC3339) - if msg.Chat.To == "" { - // TODO(longsleep): Check if chat broadcast is allowed. - if c.Hello { - atomic.AddUint64(&c.h.broadcastChatMessages, 1) - s.Broadcast(c, msg.Chat) - } - } else { - if msg.Chat.Chat.Status != nil && msg.Chat.Chat.Status.ContactRequest != nil { - err = s.ContactRequest(c, msg.Chat.To, msg.Chat.Chat.Status.ContactRequest) - if err != nil { - log.Println("Ignoring invalid contact request.", err) - return - } - msg.Chat.Chat.Status.ContactRequest.Userid = c.Session.Userid() - } - atomic.AddUint64(&c.h.unicastChatMessages, 1) - s.Unicast(c, msg.Chat.To, msg.Chat) - if msg.Chat.Chat.Mid != "" { - // Send out delivery confirmation status chat message. - s.Unicast(c, c.Id, &DataChat{To: msg.Chat.To, Type: "Chat", Chat: &DataChatMessage{Mid: msg.Chat.Chat.Mid, Status: &DataChatStatus{State: "sent"}}}) - } - } - case "Conference": - // Check conference maximum size. - if len(msg.Conference.Conference) > maxConferenceSize { - log.Println("Refusing to create conference above limit.", len(msg.Conference.Conference)) - } else { - // Send conference update to anyone. - for _, id := range msg.Conference.Conference { - if id != c.Id { - //log.Println("participant", id) - s.Unicast(c, id, msg.Conference) - } - } - } - case "Alive": - s.Alive(c, msg.Alive, msg.Iid) - case "Sessions": - s.Sessions(c, msg.Sessions.Sessions, msg.Iid) - default: - log.Println("OnText unhandled message type", msg.Type) - } - -} - -func (s *Server) Unicast(c *Connection, to string, m interface{}) { - - outgoing := &DataOutgoing{From: c.Id, To: to, Data: m} - if !c.isClosing && c.Id != to { - outgoing.A = c.Session.Attestation() - } - b := c.h.buffers.New() - encoder := json.NewEncoder(b) - err := encoder.Encode(outgoing) - if err != nil { - b.Decref() - log.Println("Unicast error while encoding JSON", err) - return - } - //log.Println("Unicast", b) - - var msg = &MessageRequest{From: c.Id, To: to, Message: b} - c.h.unicastHandler(msg) - b.Decref() -} - -func (s *Server) Broadcast(c *Connection, m interface{}) { - - b := c.h.buffers.New() - encoder := json.NewEncoder(b) - err := encoder.Encode(&DataOutgoing{From: c.Id, Data: m, A: c.Session.Attestation()}) - if err != nil { - b.Decref() - log.Println("Broadcast error while encoding JSON", err) - return - } - - if c.h.isGlobalRoomid(c.Roomid) { - c.h.RunForAllRooms(func(room *RoomWorker) { - var msg = &MessageRequest{From: c.Id, Message: b, Id: room.Id} - room.broadcastHandler(msg) - }) - } else { - var msg = &MessageRequest{From: c.Id, Message: b, Id: c.Roomid} - room := c.h.GetRoom(c.Roomid) - room.broadcastHandler(msg) - } - b.Decref() - -} - -func (s *Server) Alive(c *Connection, alive *DataAlive, iid string) { - - c.h.aliveHandler(c, alive, iid) - -} - -func (s *Server) Sessions(c *Connection, srq *DataSessionsRequest, iid string) { - - c.h.sessionsHandler(c, srq, iid) - -} - -func (s *Server) UpdateSession(c *Connection, su *SessionUpdate) uint64 { - - su.Id = c.Id - return c.h.sessionupdateHandler(su) - -} - -func (s *Server) ContactRequest(c *Connection, to string, cr *DataContactRequest) (err error) { - - return c.h.contactrequestHandler(c, to, cr) - -} - -func (s *Server) Users(c *Connection) { - - room := c.h.GetRoom(c.Roomid) - room.usersHandler(c) - -} - -func (s *Server) Authenticate(c *Connection, st *SessionToken) bool { - - err := c.h.authenticateHandler(c.Session, st, "") - if err == nil { - log.Println("Authentication success", c.Id, c.Idx, c.Session.Userid) - return true - } else { - log.Println("Authentication failed", err, c.Id, c.Idx, st.Userid, st.Nonce) - return false - } - -} - -func (s *Server) UpdateRoomConnection(c *Connection, rcu *RoomConnectionUpdate) { - - rcu.Sessionid = c.Id - rcu.Connection = c - room := c.h.GetRoom(c.Roomid) - room.connectionHandler(rcu) - -} diff --git a/src/app/spreed-webrtc-server/session.go b/src/app/spreed-webrtc-server/session.go index ccb8bacd..92623b16 100644 --- a/src/app/spreed-webrtc-server/session.go +++ b/src/app/spreed-webrtc-server/session.go @@ -39,26 +39,28 @@ type Session struct { Status interface{} Nonce string Prio int + Hello bool + Roomid string mutex sync.RWMutex userid string fake bool stamp int64 attestation *SessionAttestation + attestations *securecookie.SecureCookie subscriptions map[string]*Session subscribers map[string]*Session - h *Hub } -func NewSession(h *Hub, id, sid string) *Session { +func NewSession(attestations *securecookie.SecureCookie, id, sid string) *Session { session := &Session{ Id: id, Sid: sid, Prio: 100, stamp: time.Now().Unix(), + attestations: attestations, subscriptions: make(map[string]*Session), subscribers: make(map[string]*Session), - h: h, } session.NewAttestation() return session @@ -288,35 +290,27 @@ func (s *Session) DataSessionStatus() *DataSession { } func (s *Session) NewAttestation() { - s.attestation = &SessionAttestation{ s: s, } s.attestation.Update() - } func (s *Session) Attestation() (attestation string) { - s.mutex.RLock() attestation = s.attestation.Token() s.mutex.RUnlock() return - } func (s *Session) UpdateAttestation() { - s.mutex.Lock() s.attestation.Update() s.mutex.Unlock() - } type SessionUpdate struct { - Id string Types []string - Roomid string Ua string Prio int Status interface{} @@ -336,39 +330,31 @@ type SessionAttestation struct { } func (sa *SessionAttestation) Update() (string, error) { - token, err := sa.Encode() if err == nil { sa.token = token sa.refresh = time.Now().Unix() + 180 // expires after 3 minutes } return token, err - } func (sa *SessionAttestation) Token() (token string) { - if sa.refresh < time.Now().Unix() { token, _ = sa.Update() } else { token = sa.token } return - } func (sa *SessionAttestation) Encode() (string, error) { - - return sa.s.h.attestations.Encode("attestation", sa.s.Id) - + return sa.s.attestations.Encode("attestation", sa.s.Id) } func (sa *SessionAttestation) Decode(token string) (string, error) { - var id string - err := sa.s.h.attestations.Decode("attestation", token, &id) + err := sa.s.attestations.Decode("attestation", token, &id) return id, err - } func init() { diff --git a/src/app/spreed-webrtc-server/session_manager.go b/src/app/spreed-webrtc-server/session_manager.go new file mode 100644 index 00000000..28019253 --- /dev/null +++ b/src/app/spreed-webrtc-server/session_manager.go @@ -0,0 +1,173 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "crypto/sha256" + "net/http" + "sync" + + "github.com/gorilla/securecookie" +) + +type UserStats interface { + UserInfo(bool) (int, map[string]*DataUser) +} + +type SessionManager interface { + UserStats + RetrieveUsersWith(func(*http.Request) (string, error)) + CreateSession(*http.Request) *Session + DestroySession(*Session) + Authenticate(*Session, *SessionToken, string) error + GetUserSessions(session *Session, id string) []*DataSession +} + +type sessionManager struct { + Tickets + sync.RWMutex + config *Config + userTable map[string]*User + fakesessionTable map[string]*Session + useridRetriever func(*http.Request) (string, error) + attestations *securecookie.SecureCookie +} + +func NewSessionManager(config *Config, tickets Tickets, sessionSecret []byte) SessionManager { + sessionManager := &sessionManager{ + tickets, + sync.RWMutex{}, + config, + make(map[string]*User), + make(map[string]*Session), + nil, + nil, + } + + sessionManager.attestations = securecookie.New(sessionSecret, nil) + sessionManager.attestations.MaxAge(300) // 5 minutes + sessionManager.attestations.HashFunc(sha256.New) + + return sessionManager +} + +func (sessionManager *sessionManager) UserInfo(details bool) (userCount int, users map[string]*DataUser) { + sessionManager.RLock() + defer sessionManager.RUnlock() + + userCount = len(sessionManager.userTable) + if details { + users := make(map[string]*DataUser) + for userid, user := range sessionManager.userTable { + users[userid] = user.Data() + } + } + return +} + +func (sessionManager *sessionManager) RetrieveUsersWith(retriever func(*http.Request) (string, error)) { + sessionManager.useridRetriever = retriever +} + +func (sessionManager *sessionManager) CreateSession(request *http.Request) *Session { + request.ParseForm() + token := request.FormValue("t") + st := sessionManager.DecodeSessionToken(token) + + var userid string + if sessionManager.config.UsersEnabled { + if sessionManager.useridRetriever != nil { + userid, _ = sessionManager.useridRetriever(request) + if userid == "" { + userid = st.Userid + } + } + } + + session := NewSession(sessionManager.attestations, st.Id, st.Sid) + + if userid != "" { + // XXX(lcooper): Should errors be handled here? + sessionManager.Authenticate(session, st, userid) + } + + return session +} + +func (sessionManager *sessionManager) DestroySession(session *Session) { + session.Close() + + sessionManager.Lock() + if suserid := session.Userid(); suserid != "" { + user, ok := sessionManager.userTable[suserid] + if ok && user.RemoveSession(session) { + delete(sessionManager.userTable, suserid) + } + } + sessionManager.Unlock() +} + +func (sessionManager *sessionManager) Authenticate(session *Session, st *SessionToken, userid string) error { + if err := session.Authenticate(sessionManager.Realm(), st, userid); err != nil { + return err + } + + // Authentication success. + suserid := session.Userid() + sessionManager.Lock() + user, ok := sessionManager.userTable[suserid] + if !ok { + user = NewUser(suserid) + sessionManager.userTable[suserid] = user + } + sessionManager.Unlock() + user.AddSession(session) + return nil +} + +func (sessionManager *sessionManager) GetUserSessions(session *Session, userid string) (users []*DataSession) { + var ( + user *User + ok bool + ) + sessionManager.RLock() + user, ok = sessionManager.userTable[userid] + sessionManager.RUnlock() + if !ok { + // No user. Create fake session. + sessionManager.Lock() + session, ok := sessionManager.fakesessionTable[userid] + if !ok { + st := sessionManager.FakeSessionToken(userid) + session = NewSession(sessionManager.attestations, st.Id, st.Sid) + session.SetUseridFake(st.Userid) + sessionManager.fakesessionTable[userid] = session + } + sessionManager.Unlock() + users = make([]*DataSession, 1, 1) + users[0] = session.Data() + } else { + // Add sessions for foreign user. + users = user.SubscribeSessions(session) + } + return +} diff --git a/src/app/spreed-webrtc-server/sessions.go b/src/app/spreed-webrtc-server/sessions.go index 467dea2e..6ea0b452 100644 --- a/src/app/spreed-webrtc-server/sessions.go +++ b/src/app/spreed-webrtc-server/sessions.go @@ -23,6 +23,7 @@ package main import ( "encoding/json" + "errors" "github.com/gorilla/mux" "log" "net/http" @@ -42,7 +43,8 @@ type SessionNonceRequest struct { } type Sessions struct { - hub *Hub + SessionValidator + SessionStore users *Users } @@ -78,7 +80,7 @@ func (sessions *Sessions) Patch(request *http.Request) (int, interface{}, http.H } // Make sure Sid matches session and is valid. - if !sessions.hub.ValidateSession(snr.Id, snr.Sid) { + if !sessions.ValidateSession(snr.Id, snr.Sid) { log.Println("Session patch failed - validation failed.") error = true } @@ -104,7 +106,12 @@ func (sessions *Sessions) Patch(request *http.Request) (int, interface{}, http.H var nonce string if !error { // FIXME(longsleep): Not running this might reveal error state with a timing attack. - nonce, err = sessions.hub.sessiontokenHandler(&SessionToken{Id: snr.Id, Sid: snr.Sid, Userid: userid}) + if session, ok := sessions.GetSession(snr.Id); ok { + nonce, err = session.Authorize(sessions.Realm(), &SessionToken{Id: snr.Id, Sid: snr.Sid, Userid: userid}) + } else { + err = errors.New("no such session") + } + if err != nil { log.Println("Session patch failed - handle failed.", err) error = true diff --git a/src/app/spreed-webrtc-server/stats.go b/src/app/spreed-webrtc-server/stats.go index cf344a25..ac9ac0e5 100644 --- a/src/app/spreed-webrtc-server/stats.go +++ b/src/app/spreed-webrtc-server/stats.go @@ -33,11 +33,11 @@ type Stat struct { Hub *HubStat `json:"hub"` } -func NewStat(details bool, h *Hub) *Stat { +func NewStat(details bool, statsGenerator StatsGenerator) *Stat { stat := &Stat{ details: details, Runtime: &RuntimeStat{}, - Hub: h.Stat(details), + Hub: statsGenerator.Stat(details), } stat.Runtime.Read() return stat @@ -69,12 +69,12 @@ func (stat *RuntimeStat) Read() { } type Stats struct { - hub *Hub + StatsGenerator } func (stats *Stats) Get(request *http.Request) (int, interface{}, http.Header) { details := request.Form.Get("details") == "1" - return 200, NewStat(details, stats.hub), http.Header{"Content-Type": {"application/json; charset=utf-8"}, "Access-Control-Allow-Origin": {"*"}} + return 200, NewStat(details, stats), http.Header{"Content-Type": {"application/json; charset=utf-8"}, "Access-Control-Allow-Origin": {"*"}} } diff --git a/src/app/spreed-webrtc-server/stats_manager.go b/src/app/spreed-webrtc-server/stats_manager.go new file mode 100644 index 00000000..59163839 --- /dev/null +++ b/src/app/spreed-webrtc-server/stats_manager.go @@ -0,0 +1,104 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "sync/atomic" +) + +type HubStat struct { + Rooms int `json:"rooms"` + Connections int `json:"connections"` + Sessions int `json:"sessions"` + Users int `json:"users"` + Count uint64 `json:"count"` + BroadcastChatMessages uint64 `json:"broadcastchatmessages"` + UnicastChatMessages uint64 `json:"unicastchatmessages"` + IdsInRoom map[string][]string `json:"idsinroom,omitempty"` + SessionsById map[string]*DataSession `json:"sessionsbyid,omitempty"` + UsersById map[string]*DataUser `json:"usersbyid,omitempty"` + ConnectionsByIdx map[string]string `json:"connectionsbyidx,omitempty"` +} + +type ConnectionCounter interface { + CountConnection() uint64 +} + +type StatsCounter interface { + CountBroadcastChat() + CountUnicastChat() +} + +type StatsGenerator interface { + Stat(details bool) *HubStat +} + +type StatsManager interface { + ConnectionCounter + StatsCounter + StatsGenerator +} + +type statsManager struct { + ClientStats + RoomStats + UserStats + connectionCount uint64 + broadcastChatMessages uint64 + unicastChatMessages uint64 +} + +func NewStatsManager(clientStats ClientStats, roomStats RoomStats, userStats UserStats) StatsManager { + return &statsManager{clientStats, roomStats, userStats, 0, 0, 0} +} + +func (stats *statsManager) CountConnection() uint64 { + return atomic.AddUint64(&stats.connectionCount, 1) +} + +func (stats *statsManager) CountBroadcastChat() { + atomic.AddUint64(&stats.broadcastChatMessages, 1) +} + +func (stats *statsManager) CountUnicastChat() { + atomic.AddUint64(&stats.unicastChatMessages, 1) +} + +func (stats *statsManager) Stat(details bool) *HubStat { + roomCount, roomSessionInfo := stats.RoomInfo(details) + clientCount, sessions, connections := stats.ClientInfo(details) + userCount, users := stats.UserInfo(details) + + return &HubStat{ + Rooms: roomCount, + Connections: clientCount, + Sessions: clientCount, + Users: userCount, + Count: atomic.LoadUint64(&stats.connectionCount), + BroadcastChatMessages: atomic.LoadUint64(&stats.broadcastChatMessages), + UnicastChatMessages: atomic.LoadUint64(&stats.unicastChatMessages), + IdsInRoom: roomSessionInfo, + SessionsById: sessions, + UsersById: users, + ConnectionsByIdx: connections, + } +} diff --git a/src/app/spreed-webrtc-server/tickets.go b/src/app/spreed-webrtc-server/tickets.go new file mode 100644 index 00000000..b44f8bef --- /dev/null +++ b/src/app/spreed-webrtc-server/tickets.go @@ -0,0 +1,130 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ + +package main + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "log" + + "github.com/gorilla/securecookie" +) + +type SessionValidator interface { + Realm() string + ValidateSession(string, string) bool +} + +type SessionEncoder interface { + EncodeSessionToken(*Session) (string, error) + EncodeSessionUserID(*Session) string +} + +type Tickets interface { + SessionValidator + SessionEncoder + DecodeSessionToken(token string) (st *SessionToken) + FakeSessionToken(userid string) *SessionToken +} + +type tickets struct { + *securecookie.SecureCookie + realm string + tokenName string + encryptionSecret []byte +} + +func NewTickets(sessionSecret, encryptionSecret []byte, realm string) Tickets { + tickets := &tickets{ + nil, + realm, + fmt.Sprintf("token@%s", realm), + encryptionSecret, + } + tickets.SecureCookie = securecookie.New(sessionSecret, encryptionSecret) + tickets.MaxAge(86400 * 30) // 30 days + tickets.HashFunc(sha256.New) + tickets.BlockFunc(aes.NewCipher) + + return tickets +} + +func (tickets *tickets) Realm() string { + return tickets.realm +} + +func (tickets *tickets) DecodeSessionToken(token string) (st *SessionToken) { + var err error + if token != "" { + st = &SessionToken{} + err = tickets.Decode(tickets.tokenName, token, st) + if err != nil { + log.Println("Error while decoding session token", err) + } + } + + if st == nil || err != nil { + sid := NewRandomString(32) + id, _ := tickets.Encode("id", sid) + st = &SessionToken{Id: id, Sid: sid} + log.Println("Created new session id", id) + } + return +} + +func (tickets *tickets) FakeSessionToken(userid string) *SessionToken { + st := &SessionToken{} + st.Sid = fmt.Sprintf("fake-%s", NewRandomString(27)) + st.Id, _ = tickets.Encode("id", st.Sid) + st.Userid = userid + log.Println("Created new fake session id", st.Id) + return st +} + +func (tickets *tickets) ValidateSession(id, sid string) bool { + var decoded string + if err := tickets.Decode("id", id, &decoded); err != nil { + log.Println("Session validation error", err, id, sid) + return false + } + if decoded != sid { + log.Println("Session validation failed", id, sid) + return false + } + return true +} + +func (tickets *tickets) EncodeSessionToken(session *Session) (string, error) { + return tickets.Encode(tickets.tokenName, session.Token()) +} + +func (tickets *tickets) EncodeSessionUserID(session *Session) (suserid string) { + if userid := session.Userid(); userid != "" { + m := hmac.New(sha256.New, tickets.encryptionSecret) + m.Write([]byte(userid)) + suserid = base64.StdEncoding.EncodeToString(m.Sum(nil)) + } + return +} diff --git a/src/app/spreed-webrtc-server/users.go b/src/app/spreed-webrtc-server/users.go index ed70687f..c10fa512 100644 --- a/src/app/spreed-webrtc-server/users.go +++ b/src/app/spreed-webrtc-server/users.go @@ -291,16 +291,21 @@ func (un *UserNonce) Response() (int, interface{}, http.Header) { } type Users struct { - hub *Hub + SessionValidator + SessionManager + SessionStore realm string handler UsersHandler } -func NewUsers(hub *Hub, mode, realm string, runtime phoenix.Runtime) *Users { +func NewUsers(sessionStore SessionStore, sessionValidator SessionValidator, sessionManager SessionManager, mode, realm string, runtime phoenix.Runtime) *Users { var users = &Users{ - hub: hub, - realm: realm, + sessionValidator, + sessionManager, + sessionStore, + realm, + nil, } var handler UsersHandler @@ -309,8 +314,8 @@ func NewUsers(hub *Hub, mode, realm string, runtime phoenix.Runtime) *Users { // Create handler based on mode. if handler, err = users.createHandler(mode, runtime); handler != nil && err == nil { users.handler = handler - // Register handler Get at the hub. - users.hub.useridRetriever = func(request *http.Request) (userid string, err error) { + // Register handler Get. + sessionManager.RetrieveUsersWith(func(request *http.Request) (userid string, err error) { userid, err = handler.Get(request) if err != nil { log.Printf("Failed to get userid from handler: %s", err) @@ -320,7 +325,7 @@ func NewUsers(hub *Hub, mode, realm string, runtime phoenix.Runtime) *Users { } } return - } + }) log.Printf("Enabled users handler '%s'\n", mode) } else if err != nil { log.Printf("Failed to enable handler '%s': %s\n", mode, err) @@ -450,11 +455,20 @@ func (users *Users) Post(request *http.Request) (int, interface{}, http.Header) userid := fmt.Sprintf("%s@%s", uuid.NewV4().String(), users.realm) // Make sure Sid matches session and is valid. - if !users.hub.ValidateSession(snr.Id, snr.Sid) { + if !users.ValidateSession(snr.Id, snr.Sid) { return 403, NewApiError("users_invalid_session", "Invalid session"), http.Header{"Content-Type": {"application/json"}} } - nonce, err := users.hub.sessiontokenHandler(&SessionToken{Id: snr.Id, Sid: snr.Sid, Userid: userid}) + var ( + nonce string + err error + ) + if session, ok := users.GetSession(snr.Id); ok { + nonce, err = session.Authorize(users.Realm(), &SessionToken{Id: snr.Id, Sid: snr.Sid, Userid: userid}) + } else { + err = errors.New("no such session") + } + if err != nil { return 400, NewApiError("users_request_failed", fmt.Sprintf("Error: %q", err)), http.Header{"Content-Type": {"application/json"}} } diff --git a/src/app/spreed-webrtc-server/ws.go b/src/app/spreed-webrtc-server/ws.go index 800b9165..9bba4bfc 100644 --- a/src/app/spreed-webrtc-server/ws.go +++ b/src/app/spreed-webrtc-server/ws.go @@ -22,9 +22,10 @@ package main import ( - "github.com/gorilla/websocket" "log" "net/http" + + "github.com/gorilla/websocket" ) const ( @@ -49,10 +50,8 @@ var ( } ) -func makeWsHubHandler(h *Hub) http.HandlerFunc { - +func makeWSHandler(connectionCounter ConnectionCounter, sessionManager SessionManager, codec Codec, channellingAPI ChannellingAPI) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Validate incoming request. if r.Method != "GET" { w.WriteHeader(http.StatusMethodNotAllowed) @@ -68,30 +67,14 @@ func makeWsHubHandler(h *Hub) http.HandlerFunc { return } - // Read request details. - r.ParseForm() - token := r.FormValue("t") - // Create a new connection instance. - c := NewConnection(h, ws, r) - if token != "" { - if err := c.reregister(token); err != nil { - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - } else { - if err := c.register(); err != nil { - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - } + session := sessionManager.CreateSession(r) + defer sessionManager.DestroySession(session) + client := NewClient(codec, channellingAPI, session) + conn := NewConnection(connectionCounter.CountConnection(), ws, client) // Start pumps (readPump blocks). - go c.writePump() - c.readPump() - + go conn.writePump() + conn.readPump() } - } diff --git a/src/styles/components/_buddylist.scss b/src/styles/components/_buddylist.scss index 1e6cb04b..a338e950 100644 --- a/src/styles/components/_buddylist.scss +++ b/src/styles/components/_buddylist.scss @@ -240,7 +240,7 @@ text-align: center; vertical-align: middle; line-height: 40px; - font-size: 1.6em; + font-size: $buddylist-action-font-size; } } diff --git a/src/styles/components/_contactsmanager.scss b/src/styles/components/_contactsmanager.scss index 5f55c9f7..04a89da6 100644 --- a/src/styles/components/_contactsmanager.scss +++ b/src/styles/components/_contactsmanager.scss @@ -47,9 +47,12 @@ overflow-y: auto; } .picture { + display: table-cell; border-bottom: 0; cursor: auto; min-height: 46px; + position: static; + width: auto; .buddyPicture { margin: 0 0 0 10px; } @@ -60,7 +63,7 @@ border-top: none; } .name { - width: 70%; + width: 40%; text-align: left; vertical-align: middle; } diff --git a/src/styles/global/_variables.scss b/src/styles/global/_variables.scss index 82342339..97fe79c9 100644 --- a/src/styles/global/_variables.scss +++ b/src/styles/global/_variables.scss @@ -105,6 +105,7 @@ $buddylist-tab-background: $componentbg !default; $buddylist-action-background: rgba(255,255,255,0.5) !default; $buddylist-buddy1: $componentfg1 !default; $buddylist-buddy2: $componentfg2 !default; +$buddylist-action-font-size: 1.6em; // chat $chat-width: $pane-width !default; diff --git a/static/js/app.js b/static/js/app.js index 80bcefe0..749b37ac 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -184,6 +184,9 @@ define([ var deferred = $.Deferred(); var globalContext = JSON.parse($("#globalcontext").text()); + if (!globalContext.Cfg.Version) { + globalContext.Cfg.Version = "unknown"; + } app.constant("globalContext", globalContext); // Configure language. diff --git a/static/js/controllers/contactsmanagercontroller.js b/static/js/controllers/contactsmanagercontroller.js index 1596689c..2f2867b4 100644 --- a/static/js/controllers/contactsmanagercontroller.js +++ b/static/js/controllers/contactsmanagercontroller.js @@ -21,7 +21,15 @@ define([], function() { // ContactsmanagerController - return ["$scope", "$modalInstance", "contactData", "data", "contacts", 'dialogs', 'translation', function($scope, $modalInstance, contactData, data, contacts, dialogs, translation) { + return ["$scope", "$modalInstance", "contactData", "data", "contacts", 'dialogs', 'translation', 'mediaStream', 'buddyData', '$timeout', function($scope, $modalInstance, contactData, data, contacts, dialogs, translation, mediaStream, buddyData, $timeout) { + var getContactSessionId = function(userid) { + var session = null; + var scope = buddyData.lookup(userid, false, false); + if (scope) { + session = scope.session.get(); + } + return session && session.Id ? session.Id : null; + }; $scope.header = data.header; $scope.contacts = []; $scope.openContactsManagerEdit = function(contact) { @@ -36,7 +44,6 @@ define([], function() { } ); }; - var updateContacts = function() { $scope.contacts = contactData.getAll(); }; @@ -50,7 +57,17 @@ define([], function() { contacts.e.on('contactremoved', function() { updateContacts(); }); - + $scope.doCall = function(contact) { + mediaStream.webrtc.doCall(getContactSessionId(contact.Userid)); + $modalInstance.close(); + }; + $scope.startChat = function(contact) { + $scope.$emit("startchat", getContactSessionId(contact.Userid), { + autofocus: true, + restore: true + }); + $modalInstance.close(); + }; }]; }); diff --git a/static/js/controllers/mediastreamcontroller.js b/static/js/controllers/mediastreamcontroller.js index 23fe08a8..7e639f32 100644 --- a/static/js/controllers/mediastreamcontroller.js +++ b/static/js/controllers/mediastreamcontroller.js @@ -20,7 +20,7 @@ */ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'modernizr', 'webrtc.adapter'], function(_, BigScreen, moment, sjcl, Modernizr) { - return ["$scope", "$rootScope", "$element", "$window", "$timeout", "safeDisplayName", "safeApply", "mediaStream", "appData", "playSound", "desktopNotify", "alertify", "toastr", "translation", "fileDownload", "localStorage", "screensharing", "userSettingsData", "localStatus", "dialogs", function($scope, $rootScope, $element, $window, $timeout, safeDisplayName, safeApply, mediaStream, appData, playSound, desktopNotify, alertify, toastr, translation, fileDownload, localStorage, screensharing, userSettingsData, localStatus, dialogs) { + return ["$scope", "$rootScope", "$element", "$window", "$timeout", "safeDisplayName", "safeApply", "mediaStream", "appData", "playSound", "desktopNotify", "alertify", "toastr", "translation", "fileDownload", "localStorage", "screensharing", "userSettingsData", "localStatus", "dialogs", "rooms", function($scope, $rootScope, $element, $window, $timeout, safeDisplayName, safeApply, mediaStream, appData, playSound, desktopNotify, alertify, toastr, translation, fileDownload, localStorage, screensharing, userSettingsData, localStatus, dialogs, rooms) { /*console.log("route", $route, $routeParams, $location);*/ @@ -544,20 +544,17 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'modernizr', 'webrtc.adapte // Unmark authorization process. if (data.Userid) { - mediaStream.users.authorizing(false); - } else if (!mediaStream.users.authorizing()) { + $rootScope.authorizing(false); + $rootScope.$broadcast("authorization.succeeded"); + } else if (!$rootScope.authorizing()) { // Trigger user data load when not in authorizing phase. $scope.loadUserSettings(); } - if (!$rootScope.roomid && $scope.master.settings.defaultRoom) { + if (rooms.inDefaultRoom() && $scope.master.settings.defaultRoom) { console.log("Selecting default room from settings:", [$scope.master.settings.defaultRoom]); - mediaStream.changeRoom($scope.master.settings.defaultRoom, true); + rooms.joinByName($scope.master.settings.defaultRoom, true); } - - // Always apply room after self received to avoid double stuff. - mediaStream.applyRoom(); - }); mediaStream.webrtc.e.on("peercall", function(event, peercall) { @@ -660,36 +657,36 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'modernizr', 'webrtc.adapte } }; - mediaStream.connector.e.on("open error close", function(event, options) { - var t = event.type; - var opts = $.extend({}, options); + $scope.$on("room.joined", function(ev) { + // TODO(lcooper): Is it really needful to do this stuff? $timeout.cancel(ttlTimeout); - if (!opts.soft) { - // Reset login information for anything not soft. - $scope.userid = $scope.suserid = null; - } - switch (t) { + connected = true; + reconnecting = false; + $scope.updateStatus(true); + }); + + mediaStream.connector.e.on("open error close", function(event) { + $timeout.cancel(ttlTimeout); + $scope.userid = $scope.suserid = null; + switch (event.type) { case "open": - t = "waiting"; connected = true; reconnecting = false; $scope.updateStatus(true); - if (opts.soft) { - return; - } + $scope.setStatus("waiting"); break; case "error": if (reconnecting || connected) { reconnecting = false; reconnect(); - return; + } else { + $scope.setStatus(event.type); } break; case "close": reconnect(); - return; + break; } - $scope.setStatus(t); }); mediaStream.webrtc.e.on("waitforusermedia connecting", function(event, currentcall) { @@ -797,23 +794,6 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'modernizr', 'webrtc.adapte if (mediaStream.connector.connected) { $scope.setStatus("waiting"); } - if ($scope.roomstatus) { - $scope.layout.buddylist = true; - $scope.layout.buddylistAutoHide = false; - } else { - $scope.layout.buddylist = false; - $scope.layout.buddylistAutoHide = true; - } - }); - - $scope.$watch("roomstatus", function(roomstatus) { - if (roomstatus && !$scope.peer) { - $scope.layout.buddylist = true; - $scope.layout.buddylistAutoHide = false; - } else if (!$scope.layout.buddylistAutoHide) { - $scope.layout.buddylist = false; - $scope.layout.buddylistAutoHide = true; - } }); mediaStream.webrtc.e.on("busy", function(event, from) { diff --git a/static/js/controllers/roomchangecontroller.js b/static/js/controllers/roomchangecontroller.js index 084f4a80..16943cd4 100644 --- a/static/js/controllers/roomchangecontroller.js +++ b/static/js/controllers/roomchangecontroller.js @@ -19,72 +19,7 @@ * */ define([], function() { - - // RoomchangeController - return ["$scope", "$element", "$window", "mediaStream", "$http", "$timeout", function($scope, $element, $window, mediaStream, $http, $timeout) { - - //console.log("Room change controller", $element, $scope.roomdata); - - var url = mediaStream.url.api("rooms"); - - var ctrl = this; - ctrl.enabled = true; - - ctrl.getRoom = function(cb) { - $http({ - method: "POST", - url: url, - data: $.param({}), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }). - success(function(data, status) { - cb(data); - }). - error(function() { - console.error("Failed to retrieve room link."); - cb({}); - }); - }; - - $scope.changeRoomToId = function(id) { - return mediaStream.changeRoom(id); - }; - - $scope.refreshRoom = function() { - if (ctrl.enabled) { - ctrl.getRoom(function(roomdata) { - console.info("Retrieved room data", roomdata); - $scope.roomdata = roomdata; - $element.find(".btn-roomcreate").get(0).focus(); - }); - } - }; - - $scope.$on("$destroy", function() { - //console.log("Room change controller destroyed"); - ctrl.enabled = false; - }); - - $scope.roomdata = {}; - $scope.$watch("roomdata.name", function(n) { - //console.log("roomdata.name changed", n); - if (!n) { - n = ""; - } - var u = encodeURIComponent(n); - $scope.roomdata.url = "/" + u; - $scope.roomdata.link = mediaStream.url.room(n); - }); - - var roomDataLinkInput = $element.find(".roomdata-link-input"); - if (roomDataLinkInput.length) { - $timeout(function() { - $scope.refreshRoom(); - }, 100); - } - + return ["$scope", "rooms", function($scope, rooms) { + $scope.joinRoomByName = rooms.joinByName; }]; - }); diff --git a/static/js/directives/audiovideo.js b/static/js/directives/audiovideo.js index e752c281..1504f37c 100644 --- a/static/js/directives/audiovideo.js +++ b/static/js/directives/audiovideo.js @@ -26,8 +26,12 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ var controller = ['$scope', '$element', '$attrs', function($scope, $element, $attrs) { - var peers = {}; - var events = $({}); + var streams = {}; + var getStreamId = function(stream, currentcall) { + var id = currentcall.id + "-" + stream.id; + console.log("Created stream ID", id); + return id; + }; $scope.container = $element.get(0); $scope.layoutparent = $element.parent(); @@ -39,6 +43,9 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ $scope.hasUsermedia = false; $scope.isActive = false; + $scope.haveStreams = false; + + $scope.peersTalking = {}; $scope.rendererName = $scope.defaultRendererName = "democrazy"; @@ -46,31 +53,32 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ $scope.addRemoteStream = function(stream, currentcall) { - //console.log("Add remote stream to scope", pc.id, stream); + var id = getStreamId(stream, currentcall); + + if (streams.hasOwnProperty(id)) { + console.warn("Cowardly refusing to add stream id twice", id, currentcall); + return; + } + + //console.log("Add remote stream to scope", stream.id, stream, currentcall); // Create scope. - var subscope = $scope.$new(true); + var subscope = $scope.$new(); var peerid = subscope.peerid = currentcall.id; buddyData.push(peerid); subscope.withvideo = false; subscope.onlyaudio = false; - subscope.talking = false; subscope.destroyed = false; - subscope.applyTalking = function(talking) { - subscope.talking = !! talking; - safeApply(subscope); - }; subscope.$on("active", function() { - console.log("Stream scope is now active", peerid); - events.triggerHandler("active." + peerid, [subscope, currentcall, stream]); + console.log("Stream scope is now active", id, peerid); }); subscope.$on("$destroy", function() { - console.log("Destroyed scope for audiovideo", subscope); + console.log("Destroyed scope for stream", id, peerid); subscope.destroyed = true; }); - console.log("Created stream scope", peerid); + console.log("Created stream scope", id, peerid); // Add created scope. - peers[peerid] = subscope; + streams[id] = subscope; // Render template. peerTemplate(subscope, function(clonedElement, scope) { @@ -118,10 +126,13 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ $scope.removeRemoteStream = function(stream, currentcall) { - var subscope = peers[currentcall.id]; + //console.log("remove stream", stream, stream.id, currentcall); + var id = getStreamId(stream, currentcall); + + var subscope = streams[id]; if (subscope) { buddyData.pop(currentcall.id); - delete peers[currentcall.id]; + delete streams[id]; //console.log("remove scope", subscope); if (subscope.element) { subscope.element.remove(); @@ -134,17 +145,9 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ // Talking updates receiver. mediaStream.api.e.on("received.talking", function(event, id, from, talking) { - var scope = peers[from]; - //console.log("received.talking", talking, scope); - if (scope) { - scope.applyTalking(talking); - } else { - console.log("Received talking state without scope -> adding event.", from, talking); - events.one("active." + from, function(event, scope) { - console.log("Applying previously received talking state", from, talking); - scope.applyTalking(talking); - }); - } + $scope.$apply(function(scope) { + scope.peersTalking[from] = !!talking; + }); }); $scope.$on("active", function(currentcall) { @@ -177,27 +180,36 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ mediaStream.webrtc.e.on("usermedia", function(event, usermedia) { - //console.log("XXXXXXXXXXXXXXXXXXXXXXXXX usermedia event", usermedia); - $scope.hasUsermedia = true; - usermedia.attachMediaStream($scope.localVideo); - var count = 0; - var waitForLocalVideo = function() { - if (!$scope.hasUsermedia) { - return; - } - if ($scope.localVideo.videoWidth > 0) { - $scope.localVideo.style.opacity = 1; - $scope.redraw(); - } else { - count++; - if (count < 100) { - setTimeout(waitForLocalVideo, 100); + //console.log("XXXX XXXXXXXXXXXXXXXXXXXXX usermedia event", usermedia); + if ($scope.haveStreams) { + + usermedia.attachMediaStream($scope.miniVideo); + $scope.redraw(); + + } else { + + $scope.hasUsermedia = true; + usermedia.attachMediaStream($scope.localVideo); + var count = 0; + var waitForLocalVideo = function() { + if (!$scope.hasUsermedia) { + return; + } + if ($scope.localVideo.videoWidth > 0) { + $scope.localVideo.style.opacity = 1; + $scope.redraw(); } else { - console.warn("Timeout while waiting for local video.") + count++; + if (count < 100) { + setTimeout(waitForLocalVideo, 100); + } else { + console.warn("Timeout while waiting for local video.") + } } - } - }; - waitForLocalVideo(); + }; + waitForLocalVideo(); + + } }); @@ -205,6 +217,7 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ $scope.hasUsermedia = false; $scope.isActive = false; + $scope.peersTalking = {}; if (BigScreen.enabled) { BigScreen.exit(); } @@ -220,20 +233,22 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ $scope.localVideo.style.opacity = 0; $scope.remoteVideos.style.opacity = 0; $element.removeClass('active'); - _.each(peers, function(scope, k) { + _.each(streams, function(scope, k) { scope.$destroy(); - delete peers[k]; + delete streams[k]; }); $scope.rendererName = $scope.defaultRendererName; + $scope.haveStreams = false; }); mediaStream.webrtc.e.on("streamadded", function(event, stream, currentcall) { console.log("Remote stream added.", stream, currentcall); - if (_.isEmpty(peers)) { + if (!$scope.haveStreams) { //console.log("First stream"); $window.reattachMediaStream($scope.miniVideo, $scope.localVideo); + $scope.haveStreams = true; } $scope.addRemoteStream(stream, currentcall); @@ -247,7 +262,7 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ }); return { - peers: peers + streams: streams }; }]; diff --git a/static/js/directives/buddylist.js b/static/js/directives/buddylist.js index b458d768..12102743 100644 --- a/static/js/directives/buddylist.js +++ b/static/js/directives/buddylist.js @@ -21,7 +21,7 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { // buddyList - return ["$compile", "buddyList", "mediaStream", "contacts", function($compile, buddyList, mediaStream, contacts) { + return ["buddyList", "api", "webrtc", "contacts", function(buddyList, api, webrtc, contacts) { //console.log("buddyList directive"); @@ -30,10 +30,34 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { $scope.layout.buddylist = false; $scope.layout.buddylistAutoHide = true; - $scope.doCall = function(id) { + var inRoom = false; + var updateBuddyListVisibility = function() { + if (inRoom && !$scope.peer) { + $scope.layout.buddylist = true; + $scope.layout.buddylistAutoHide = false; + } else if (!$scope.layout.buddylistAutoHide) { + $scope.layout.buddylist = false; + $scope.layout.buddylistAutoHide = true; + } + }; + + webrtc.e.on("done", function() { + updateBuddyListVisibility(); + }); - mediaStream.webrtc.doCall(id); + $scope.$on("room.joined", function(ev) { + inRoom = true; + updateBuddyListVisibility(); + }); + $scope.$on("room.left", function(ev) { + inRoom = false; + buddylist.onClosed(); + updateBuddyListVisibility(); + }); + + $scope.doCall = function(id) { + webrtc.doCall(id); }; $scope.doChat = function(id) { @@ -61,23 +85,6 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { }; - /* - $scope.doAudioConference = function(id) { - - $scope.updateAutoAccept(id); - mediaStream.api.sendChat(id, null, { - AutoCall: { - Type: "conference", - Id: mediaStream.connector.roomid - } - }) - - };*/ - - $scope.setRoomStatus = function(status) { - $scope.$emit("roomStatus", status); - }; - var buddylist = $scope.buddylist = buddyList.buddylist($element, $scope, {}); var onJoined = _.bind(buddylist.onJoined, buddylist); var onLeft = _.bind(buddylist.onLeft, buddylist); @@ -85,15 +92,14 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { var onContactAdded = _.bind(buddylist.onContactAdded, buddylist); var onContactRemoved = _.bind(buddylist.onContactRemoved, buddylist); var onContactUpdated = _.bind(buddylist.onContactUpdated, buddylist); - mediaStream.api.e.on("received.userleftorjoined", function(event, dataType, data) { + api.e.on("received.userleftorjoined", function(event, dataType, data) { if (dataType === "Left") { onLeft(data); } else { onJoined(data); } }); - mediaStream.api.e.on("received.users", function(event, data) { - $scope.setRoomStatus(true); + api.e.on("received.users", function(event, data) { var selfId = $scope.id; _.each(data, function(p) { if (p.Id !== selfId) { @@ -102,17 +108,10 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { }); $scope.$apply(); }); - mediaStream.api.e.on("received.status", function(event, data) { + api.e.on("received.status", function(event, data) { onStatus(data); }); - mediaStream.connector.e.on("closed error", function() { - $scope.setRoomStatus(false); - buddylist.onClosed(); - }); - // Request user list whenever the connection comes ready. - mediaStream.connector.ready(function() { - mediaStream.api.requestUsers(); - }); + // Contacts. contacts.e.on("contactadded", function(event, data) { onContactAdded(data); diff --git a/static/js/directives/chat.js b/static/js/directives/chat.js index 1b35c6ec..dcbf0a06 100644 --- a/static/js/directives/chat.js +++ b/static/js/directives/chat.js @@ -44,16 +44,13 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], var res = []; for (var i = 0; i < ctrl.visibleRooms.length; i++) { var r = rooms[ctrl.visibleRooms[i]]; - if (!r || r.id === ctrl.group) { + if (!r) { continue; } res.push(r); } return res; }; - $scope.getGroupRoom = function() { - return rooms[ctrl.group]; - }; mediaStream.api.e.on("received.chat", function(event, id, from, data, p2p) { @@ -182,22 +179,31 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], scope.showGroupRoom = function(settings, options) { var stngs = $.extend({ - title: translation._("Room chat") + title: translation._("Room chat"), + group: true }, settings); return scope.showRoom(controller.group, stngs, options); }; + scope.hideGroupRoom = function(settings, options) { + return scope.hideRoom(controller.group); + }; + scope.showRoom = function(id, settings, opts) { var options = $.extend({}, opts); var subscope = controller.rooms[id]; var index = controller.visibleRooms.length; if (!subscope) { console.log("Create new chatroom", [id]); - controller.visibleRooms.push(id); + if (settings.group) { + controller.visibleRooms.unshift(id); + } else { + controller.visibleRooms.push(id); + } subscope = controller.rooms[id] = scope.$new(); translation.inject(subscope); subscope.id = id; - subscope.isgroupchat = id === controller.group ? true : false; + subscope.isgroupchat = !!settings.group; subscope.index = index; subscope.settings = settings; subscope.visible = false; @@ -474,11 +480,6 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], scope.currentRoomActive = false; } if (!controller.visibleRooms.length) { - scope.showGroupRoom(null, { - restore: true, - noenable: true, - noactivate: true - }); // If last visible room was removed, hide chat. scope.layout.chat = false; } @@ -544,18 +545,23 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], scope.layout.chatMaximized = false; }); - scope.$on("room", function(event, room) { + scope.$on("room.updated", function(event, room) { var subscope = scope.showGroupRoom(null, { restore: true, noenable: true, noactivate: true }); - if (room) { - var msg = $("").text(translation._("You are now in room %s ...", room)); + if (scope.currentRoomName != room.Name) { + var msg = $("").text(translation._("You are now in room %s ...", room.Name)); subscope.$broadcast("display", null, $("").append(msg)); + scope.currentRoomName = room.Name; } }); + scope.$on("room.left", function(event) { + scope.hideGroupRoom(); + scope.currentRoomName = null; + }); }; }; diff --git a/static/js/directives/directives.js b/static/js/directives/directives.js index fea45f1b..5c60c194 100644 --- a/static/js/directives/directives.js +++ b/static/js/directives/directives.js @@ -43,7 +43,8 @@ define([ 'directives/odfcanvas', 'directives/presentation', 'directives/youtubevideo', - 'directives/bfi'], function(_, onEnter, onEscape, statusMessage, buddyList, buddyPictureCapture, buddyPictureUpload, settings, chat, audioVideo, usability, audioLevel, fileInfo, screenshare, roomBar, socialShare, page, contactRequest, defaultDialog, pdfcanvas, odfcanvas, presentation, youtubevideo, bfi) { + 'directives/bfi', + 'directives/title'], function(_, onEnter, onEscape, statusMessage, buddyList, buddyPictureCapture, buddyPictureUpload, settings, chat, audioVideo, usability, audioLevel, fileInfo, screenshare, roomBar, socialShare, page, contactRequest, defaultDialog, pdfcanvas, odfcanvas, presentation, youtubevideo, bfi, title) { var directives = { onEnter: onEnter, @@ -68,13 +69,14 @@ define([ odfcanvas: odfcanvas, presentation: presentation, youtubevideo: youtubevideo, - bfi: bfi + bfi: bfi, + title: title }; var initialize = function(angModule) { _.each(directives, function(directive, name) { angModule.directive(name, directive); - }) + }); }; return { diff --git a/static/js/directives/page.js b/static/js/directives/page.js index 90f4dcd1..8967021a 100644 --- a/static/js/directives/page.js +++ b/static/js/directives/page.js @@ -20,55 +20,37 @@ */ define(['text!partials/page.html', 'text!partials/page/welcome.html'], function(template, welcome) { - return ["$templateCache", "mediaStream", function($templateCache, mediaStream) { - + return ["$templateCache", "$timeout", "rooms", function($templateCache, $timeout, rooms) { $templateCache.put('page/welcome.html', welcome); - var link = function(scope, element, attrs) { - - scope.room = false; - scope.page = null; - - if (mediaStream.config.DefaultRoomEnabled !== true) { - - scope.$on("welcome", function() { - if (!scope.initialized) { - scope.initialized = true; - scope.refresh(); - } - }); + var link = function($scope, $element, attrs) { + $scope.randomRoom = rooms.randomRoom; - scope.$on("room", function(event, room) { - scope.initialized = true; - scope.room = room !== null ? true : false; - scope.refresh(); - }); + $scope.$on("room.joined", function(event) { + $scope.page = null; + }); - scope.$watch("status", function(event) { - if (scope.initialized) { - scope.refresh(); - } + $scope.$on("room.random", function(ev, roomdata) { + $scope.page = "page/welcome.html"; + $scope.roomdata = roomdata; + $timeout(function() { + $element.find(".btn-roomcreate:visible:enabled:first").focus(); }); + }); - scope.refresh = function() { - if (scope.roomid || scope.room || scope.status !== "waiting") { - scope.page = null; - } else { - scope.page = "page/welcome.html"; - } - }; - - } - + $scope.roomdata = {}; + $scope.$watch("roomdata.name", function(name) { + $scope.roomdata.link = rooms.link($scope.roomdata); + }); }; return { restrict: 'E', replace: true, template: template, + controller: "RoomchangeController", link: link - } - + }; }]; }); diff --git a/static/js/directives/roombar.js b/static/js/directives/roombar.js index 8cccef52..2ab20274 100644 --- a/static/js/directives/roombar.js +++ b/static/js/directives/roombar.js @@ -21,17 +21,19 @@ define(['underscore', 'text!partials/roombar.html'], function(_, template) { // roomBar - return ["$window", "$rootScope", "mediaStream", function($window, $rootScope, mediaStream) { + return ["$window", "rooms", function($window, rooms) { var link = function($scope) { + var clearRoomName = function(ev) { + $scope.currentRoomName = $scope.newRoomName = ""; + }; //console.log("roomBar directive link", arguments); - $scope.newroomid = $rootScope.roomid; $scope.layout.roombar = false; $scope.save = function() { - var roomid = mediaStream.changeRoom($scope.newroomid); - if (roomid !== $rootScope.roomid) { + var roomName = rooms.joinByName($scope.newRoomName); + if (roomName !== $scope.currentRoomName) { $scope.roombarform.$setPristine(); } $scope.layout.roombar = false; @@ -44,23 +46,23 @@ define(['underscore', 'text!partials/roombar.html'], function(_, template) { }; $scope.exit = function() { - $scope.newroomid = ""; + $scope.newRoomName = ""; $scope.save(); }; - $rootScope.$watch("roomid", function(newroomid, roomid) { - if (!newroomid) { - newroomid = ""; - } - $scope.newroomid = newroomid; + $scope.$on("room.updated", function(ev, room) { + $scope.currentRoomName = $scope.newRoomName = room.Name; }); - $scope.$watch("newroomid", function(newroomid) { - if (newroomid === $rootScope.roomid) { + $scope.$on("room.left", clearRoomName); + + $scope.$watch("newRoomName", function(name) { + if (name === $scope.currentRoomName) { $scope.roombarform.$setPristine(); } }); + clearRoomName(); }; return { diff --git a/static/js/directives/socialshare.js b/static/js/directives/socialshare.js index 6da365af..e38a71b3 100644 --- a/static/js/directives/socialshare.js +++ b/static/js/directives/socialshare.js @@ -29,7 +29,7 @@ define(['jquery', 'text!partials/socialshare.html'], function($, template) { }; // socialShare - return ["$window", "translation", function($window, translation) { + return ["$window", "translation", "rooms", function($window, translation, rooms) { var title = $window.encodeURIComponent($window.document.title); var makeUrl = function(nw, target) { @@ -46,6 +46,14 @@ define(['jquery', 'text!partials/socialshare.html'], function($, template) { template: template, replace: true, link: function($scope, $element, $attr) { + $scope.$on("room.updated", function(ev, room) { + $scope.roomlink = rooms.link(room); + }); + + $scope.$on("room.left", function(ev) { + $scope.roomlink = null; + }); + $element.on("click", "a", function(event) { var nw = $(event.currentTarget).data("nw"); var url = makeUrl(nw, $scope.roomlink); diff --git a/static/js/directives/title.js b/static/js/directives/title.js new file mode 100644 index 00000000..c35ecc69 --- /dev/null +++ b/static/js/directives/title.js @@ -0,0 +1,48 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([], function() { + return [function() { + var link = function($scope, $element, attrs) { + var originalText = $element.text(); + var updateTitle = function(roomName) { + if (roomName) { + $element.text(roomName+ " - " + originalText); + } else { + $element.text(originalText); + } + }; + + $scope.$on("room.updated", function(ev, room) { + updateTitle(room.Name); + }); + + $scope.$on("room.left", function(ev) { + updateTitle(); + }); + }; + + return { + restrict: 'E', + replace: false, + link: link + }; + }]; +}); diff --git a/static/js/directives/usability.js b/static/js/directives/usability.js index 470aa7e7..d9a9194f 100644 --- a/static/js/directives/usability.js +++ b/static/js/directives/usability.js @@ -22,15 +22,13 @@ define(['jquery', 'underscore', 'text!partials/usability.html'], function($, _, var MEDIA_CHECK = "1" // First version of media check flag. - return ["mediaStream", function(mediaStream) { + return [function() { - var controller = ['$scope', "mediaStream", "safeApply", "$timeout", "localStorage", "continueConnector", function($scope, mediaStream, safeApply, $timeout, localStorage, continueConnector) { + var controller = ['$scope', "webrtc", "safeApply", "$timeout", "localStorage", "continueConnector", function($scope, webrtc, safeApply, $timeout, localStorage, continueConnector) { var pending = true; var complete = false; - var initializer = null; - var ctrl = this; ctrl.setInfo = function(info) { $scope.usabilityInfo = info; @@ -46,16 +44,7 @@ define(['jquery', 'underscore', 'text!partials/usability.html'], function($, _, localStorage.setItem("mediastream-mediacheck", MEDIA_CHECK) console.log("Continue with connect after media check ..."); continueDeferred.resolve(); - if (mediaStream.config.DefaultRoomEnabled !== true) { - ctrl.setInfo("initializing"); - initializer = $timeout(function() { - ctrl.setInfo("ok"); - $scope.layout.settings = false; - $scope.$emit("welcome"); - }, 1000); - } else { - ctrl.setInfo("ok"); - } + ctrl.setInfo("ok"); complete = true; } else { ctrl.setInfo("denied"); @@ -70,7 +59,7 @@ define(['jquery', 'underscore', 'text!partials/usability.html'], function($, _, // NOTE(longsleep): Checkin for media access makes only sense on // Chrome for now, as its the only one which remembers this // decision permanently for https. - mediaStream.webrtc.testMediaAccess($scope.continueConnect); + webrtc.testMediaAccess($scope.continueConnect); } else { $scope.continueConnect(true); } @@ -97,19 +86,16 @@ define(['jquery', 'underscore', 'text!partials/usability.html'], function($, _, } }); - $scope.$on("room", function(event, room) { - //console.log("roomStatus", room !== null ? true : false); + $scope.$on("room.joined", function(event) { if (complete) { - if (initializer !== null) { - $timeout.cancel(initializer); - initializer = null; - } - // Check if we should show settings per default when in a room. - if(room && !$scope.loadedUser) { - $scope.layout.settings = true; - } else { - $scope.layout.settings = false; - } + $scope.layout.settings = !$scope.loadedUser; + ctrl.setInfo("ok"); + } + }); + + $scope.$on("room.left", function(event) { + if (complete) { + $scope.layout.settings = false; ctrl.setInfo("ok"); } }); diff --git a/static/js/mediastream/api.js b/static/js/mediastream/api.js index 5164f4ae..ca56ddf5 100644 --- a/static/js/mediastream/api.js +++ b/static/js/mediastream/api.js @@ -18,13 +18,13 @@ * along with this program. If not, see . * */ -define(['jquery', 'underscore'], function($, _) { +define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { var alive_check_timeout = 5000; var alive_check_timeout_2 = 10000; - var Api = function(connector) { - + var Api = function(version, connector) { + this.version = version; this.id = null; this.sid = null; this.session = {}; @@ -33,6 +33,15 @@ define(['jquery', 'underscore'], function($, _) { this.e = $({}); + var ua = uaparser(); + if (ua.os.name && /Spreed Desktop Caller/i.test(ua.ua)) { + this.userAgent = ua.ua.match(/Spreed Desktop Caller\/([\d.]+)/i)[1] + " (" + ua.os.name + ")"; + } else if (ua.browser.name) { + this.userAgent = ua.browser.name + " " + ua.browser.major; + } else { + this.userAgent = ua.ua; + } + connector.e.on("received", _.bind(function(event, data) { this.received(data); }, this)); @@ -92,7 +101,7 @@ define(['jquery', 'underscore'], function($, _) { return this.apply(name, obj); }; - Api.prototype.request = function(type, data, cb) { + Api.prototype.request = function(type, data, cb, noqueue) { var payload = { Type: type @@ -103,7 +112,7 @@ define(['jquery', 'underscore'], function($, _) { payload.Iid = iid; this.e.one(iid+".request", cb); } - this.connector.send(payload); + this.connector.send(payload, noqueue); } @@ -199,6 +208,9 @@ define(['jquery', 'underscore'], function($, _) { // Do nothing. //console.log("Alive response received."); break; + case "Room": + this.e.triggerHandler("received.room", [data]); + break; default: console.log("Unhandled type received:", dataType, data); break; @@ -217,6 +229,37 @@ define(['jquery', 'underscore'], function($, _) { }; + Api.prototype.sendHello = function(name, pin, success, fault) { + var data = { + Version: this.version, + Ua: this.userAgent, + Id: name + }; + + if (pin) { + data.Credentials = { + PIN: pin + }; + } + + var that = this; + var onResponse = function(event, type, data) { + if (type === "Welcome") { + if (success) { + success(data.Room); + } + that.e.triggerHandler("received.room", [data.Room]); + that.e.triggerHandler("received.users", [data.Users]); + } else { + if (fault) { + fault(data); + } + } + }; + + this.request("Hello", data, onResponse, true); + }; + Api.prototype.sendOffer = function(to, payload) { var data = { @@ -253,6 +296,21 @@ define(['jquery', 'underscore'], function($, _) { } + Api.prototype.requestRoomUpdate = function(room, success, fault) { + var onResponse = function(event, type, data) { + if (type === "Room") { + if (success) { + success(data); + } + } else { + if (fault) { + fault(data); + } + } + }; + this.request("Room", room, onResponse, true); + }; + Api.prototype.requestUsers = function() { var data = { diff --git a/static/js/mediastream/connector.js b/static/js/mediastream/connector.js index 3718106a..d216dc9d 100644 --- a/static/js/mediastream/connector.js +++ b/static/js/mediastream/connector.js @@ -18,14 +18,12 @@ * along with this program. If not, see . * */ -define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { +define(['jquery', 'underscore'], function($, _, uaparser) { var timeout = 5000; var timeout_max = 20000; - var Connector = function(version) { - - this.version = version; + var Connector = function() { this.e = $({}); this.error = false; this.connected = false; @@ -35,18 +33,6 @@ define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { this.token = null; this.queue = []; - - this.roomid = null; - - var ua = uaparser(); - if (ua.os.name && /Spreed Desktop Caller/i.test(ua.ua)) { - this.userAgent = ua.ua.match(/Spreed Desktop Caller\/([\d.]+)/i)[1] + " (" + ua.os.name + ")"; - } else if (ua.browser.name) { - this.userAgent = ua.browser.name + " " + ua.browser.major; - } else { - this.userAgent = ua.ua; - } - }; Connector.prototype.connect = function(url) { @@ -110,7 +96,6 @@ define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { Connector.prototype.close = function() { this.connected = false; - this.roomid = null; if (this.conn) { var conn = this.conn; this.conn = null; @@ -131,42 +116,7 @@ define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { }; - Connector.prototype.room = function(roomid) { - - var was_connected = this.connected; - - if (was_connected) { - if (this.roomid === roomid) { - return; - } - this.e.triggerHandler("closed", [{ - soft: true - }]); - } - - this.roomid = roomid; - roomid = this.roomid ? this.roomid : ""; - - this.send({ - Type: "Hello", - Hello: { - Version: this.version, - Ua: this.userAgent, - Id: roomid - } - }, true); - this.e.triggerHandler("helloed", [roomid]); - - if (was_connected) { - this.e.triggerHandler("open", [{ - soft: true - }]); - } - - }; - Connector.prototype.onopen = function(event) { - window.clearTimeout(this.connecting); this.connecting_timeout = timeout; @@ -181,9 +131,6 @@ define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { data = this.queue.shift(); this.send(data); } - - this.e.triggerHandler("opened"); - }; Connector.prototype.onerror = function(event) { @@ -210,8 +157,6 @@ define(['jquery', 'underscore', 'ua-parser'], function($, _, uaparser) { if (!this.error) { this.e.triggerHandler("close", [null, event]); } - this.e.triggerHandler("closed", [null, event]); - }; Connector.prototype.onmessage = function(event) { diff --git a/static/js/mediastream/peercall.js b/static/js/mediastream/peercall.js index a56f3202..d7f14b38 100644 --- a/static/js/mediastream/peercall.js +++ b/static/js/mediastream/peercall.js @@ -138,7 +138,7 @@ define(['jquery', 'underscore', 'mediastream/utils', 'mediastream/peerconnection // after the remote SDP was set successfully. _.defer(_.bind(function() { _.each(peerconnection.getRemoteStreams(), _.bind(function(stream) { - if (!this.streams.hasOwnProperty(stream) && (stream.getAudioTracks().length > 0 || stream.getVideoTracks().length > 0)) { + if (!this.streams.hasOwnProperty(stream.id) && (stream.getAudioTracks().length > 0 || stream.getVideoTracks().length > 0)) { // NOTE(longsleep): Add stream here when it has at least one audio or video track, to avoid FF >= 33 to add it multiple times. console.log("Adding stream after remote SDP success.", stream); this.onRemoteStreamAdded(stream); @@ -182,7 +182,11 @@ define(['jquery', 'underscore', 'mediastream/utils', 'mediastream/peerconnection PeerCall.prototype.onRemoteStreamAdded = function(stream) { - this.streams[stream] = true; + var id = stream.id; + if (this.streams.hasOwnProperty(id)) { + return; + } + this.streams[id] = stream; this.e.triggerHandler("remoteStreamAdded", [stream, this]); }; @@ -191,16 +195,17 @@ define(['jquery', 'underscore', 'mediastream/utils', 'mediastream/peerconnection this.e.triggerHandler("remoteStreamRemoved", [stream, this]); if (stream) { - delete this.streams[stream]; + delete this.streams[stream.id]; } }; - PeerCall.prototype.onNegotiationNeeded = function(peerconnection) { + PeerCall.prototype.onNegotiationNeeded = function() { if (!this.negotiationNeeded) { this.negotiationNeeded = true; console.log("Negotiation needed.", this); + this.e.triggerHandler("negotiationNeeded", [this]); } }; @@ -298,13 +303,18 @@ define(['jquery', 'underscore', 'mediastream/utils', 'mediastream/peerconnection datachannel.close(); }); this.datachannels = {}; - this.streams = {}; if (this.peerconnection) { this.peerconnection.close(); this.peerconnection = null; } + // Trigger event for all previously added streams. + _.each(this.streams, _.bind(function(stream, id) { + this.e.triggerHandler("remoteStreamRemoved", [stream, this]); + }, this)); + this.streams = {}; + console.log("Peercall close", this); this.e.triggerHandler("closed", [this]); diff --git a/static/js/mediastream/peerconference.js b/static/js/mediastream/peerconference.js index cda53304..b1a450c3 100644 --- a/static/js/mediastream/peerconference.js +++ b/static/js/mediastream/peerconference.js @@ -92,13 +92,16 @@ define(['underscore', 'mediastream/peercall'], function(_, PeerCall) { console.log("Creating PeerConnection", call); call.createPeerConnection(_.bind(function(peerconnection) { // Success call. + call.e.on("negotiationNeeded", _.bind(function(event, extracall) { + this.webrtc.sendOfferWhenNegotiationNeeded(extracall); + }, this)); if (this.webrtc.usermedia) { this.webrtc.usermedia.addToPeerConnection(peerconnection); } - call.createOffer(_.bind(function(sessionDescription, extracall) { + /*call.createOffer(_.bind(function(sessionDescription, extracall) { console.log("Sending offer with sessionDescription", sessionDescription, extracall.id); this.webrtc.api.sendOffer(extracall.id, sessionDescription); - }, this)); + }, this));*/ }, this), _.bind(function() { // Error call. console.error("Failed to create peer connection for conference call."); diff --git a/static/js/mediastream/peerconnection.js b/static/js/mediastream/peerconnection.js index 760835e3..02674cba 100644 --- a/static/js/mediastream/peerconnection.js +++ b/static/js/mediastream/peerconnection.js @@ -36,7 +36,7 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { this.createPeerConnection(currentcall); } - } + }; PeerConnection.prototype.createPeerConnection = function(currentcall) { @@ -70,11 +70,22 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { // for example https://bugzilla.mozilla.org/show_bug.cgi?id=998546. pc.onaddstream = _.bind(this.onRemoteStreamAdded, this); pc.onremovestream = _.bind(this.onRemoteStreamRemoved, this); - pc.onnegotiationneeded = _.bind(this.onNegotiationNeeded, this); + if (webrtcDetectedBrowser === "firefox") { + // NOTE(longsleep): onnegotiationneeded is not supported by Firefox. We trigger it + // manually when a stream is added or removed. + // https://bugzilla.mozilla.org/show_bug.cgi?id=840728 + this.negotiationNeeded = _.bind(function() { + if (this.currentcall.initiate) { + // Trigger onNegotiationNeeded once for Firefox. + console.log("Negotiation needed."); + this.onNegotiationNeeded({target: this.pc}); + } + }, this); + } else { + pc.onnegotiationneeded = _.bind(this.onNegotiationNeeded, this); + } pc.ondatachannel = _.bind(this.onDatachannel, this); pc.onsignalingstatechange = function(event) { - // XXX(longsleep): Remove this or handle it in a real function. - // XXX(longsleep): Firefox 25 does send event as a string (like stable). console.debug("Signaling state changed", pc.signalingState); }; // NOTE(longsleep): @@ -111,6 +122,10 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { }; + PeerConnection.prototype.negotiationNeeded = function() { + // Per default this does nothing as the browser is expected to handle this. + }; + PeerConnection.prototype.createDatachannel = function(label, init) { if (!label) { @@ -224,13 +239,9 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { PeerConnection.prototype.onNegotiationNeeded = function(event) { - // XXX(longsleep): Renegotiation seems to break video streams on Chrome 31. - // XXX(longsleep): Renegotiation can happen from both sides, meaning this - // could switch offer/answer side - oh crap. var peerconnection = event.target; if (peerconnection === this.pc) { - //console.log("Negotiation needed.", peerconnection.remoteDescription, peerconnection.iceConnectionState, peerconnection.signalingState, this); - this.currentcall.onNegotiationNeeded(this); + this.currentcall.onNegotiationNeeded(); } }; @@ -244,8 +255,6 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { this.pc.close(); } - this.currentcall.onRemoteStreamRemoved(null); - this.datachannel = null; this.pc = null; @@ -271,12 +280,14 @@ define(['jquery', 'underscore', 'webrtc.adapter'], function($, _) { PeerConnection.prototype.addStream = function() { + _.defer(this.negotiationNeeded); return this.pc.addStream.apply(this.pc, arguments); }; PeerConnection.prototype.removeStream = function() { + _.defer(this.negotiationNeeded); return this.pc.removeStream.apply(this.pc, arguments); }; diff --git a/static/js/mediastream/usermedia.js b/static/js/mediastream/usermedia.js index 2c1d9db5..9b225631 100644 --- a/static/js/mediastream/usermedia.js +++ b/static/js/mediastream/usermedia.js @@ -22,6 +22,11 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ // Create AudioContext singleton, if supported. var context = AudioContext ? new AudioContext() : null; + var peerconnections = {}; + + // Disabled for now until browser support matures. If enabled this totally breaks + // Firefox and Chrome with Firefox interop. + var enableRenegotiationSupport = false; var UserMedia = function(options) { @@ -32,9 +37,14 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ this.started = false; this.delay = 0; + this.audioMute = false; + this.videoMute = false; + this.mediaConstraints = null; + // Audio level. this.audioLevel = 0; if (!this.options.noaudio && context && context.createScriptProcessor) { + this.audioSource = null; this.audioProcessor = context.createScriptProcessor(2048, 1, 1); this.audioProcessor.onaudioprocess = _.bind(function(event) { @@ -54,8 +64,34 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ this.audioLevel = rms; //console.log("this.audioLevel", this.audioLevel); }, this); + + // Connect stream to audio processor if supported. + if (context.createMediaStreamSource) { + this.e.on("localstream", _.bind(function(event, stream) { + if (this.audioSource) { + this.audioSource.disconnect(); + } + // Connect to audioProcessor. + this.audioSource = context.createMediaStreamSource(stream); + //console.log("got source", this.audioSource); + this.audioSource.connect(this.audioProcessor); + this.audioProcessor.connect(context.destination); + }, this)); + } + } + this.e.on("localstream", _.bind(function(event, stream, oldstream) { + // Update stream support. + if (oldstream) { + _.each(peerconnections, function(pc) { + pc.removeStream(oldstream); + pc.addStream(stream); + console.log("Updated usermedia stream at peer connection", pc, stream); + }); + } + }, this)); + }; // Static. @@ -112,11 +148,30 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ if (!mediaConstraints) { mediaConstraints = currentcall.mediaConstraints; } + this.mediaConstraints = mediaConstraints; + + return this.doGetUserMediaWithConstraints(mediaConstraints); + + }; + + UserMedia.prototype.doGetUserMediaWithConstraints = function(mediaConstraints) { + + if (!mediaConstraints) { + mediaConstraints = this.mediaConstraints; + } + + var constraints = $.extend(true, {}, mediaConstraints); + if (this.audioMute) { + constraints.audio = false; + } + if (this.videoMute) { + constraints.video = false; + } try { console.log('Requesting access to local media with mediaConstraints:\n' + - ' \'' + JSON.stringify(mediaConstraints) + '\'', mediaConstraints); - getUserMedia(mediaConstraints, _.bind(this.onUserMediaSuccess, this), _.bind(this.onUserMediaError, this)); + ' \'' + JSON.stringify(constraints) + '\'', constraints); + getUserMedia(constraints, _.bind(this.onUserMediaSuccess, this), _.bind(this.onUserMediaError, this)); this.started = true; return true; } catch (e) { @@ -134,27 +189,7 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ return; } - // Get notified of end events. - stream.onended = _.bind(function(event) { - console.log("User media stream ended."); - if (this.started) { - this.stop(); - } - }, this); - - if (this.audioProcessor && context.createMediaStreamSource) { - // Connect to audioProcessor. - this.audioSource = context.createMediaStreamSource(stream); - //console.log("got source", this.audioSource); - this.audioSource.connect(this.audioProcessor); - this.audioProcessor.connect(context.destination); - } - this.localStream = stream; - - // Let webrtc handle the rest. - setTimeout(_.bind(function() { - this.e.triggerHandler("mediasuccess", [this]); - }, this), this.delay); + this.onLocalStream(stream); }; @@ -170,6 +205,36 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ }; + UserMedia.prototype.onLocalStream = function(stream) { + + var oldStream = this.localStream; + if (oldStream) { + oldStream.onended = function() {}; + oldStream.stop(); + setTimeout(_.bind(function() { + this.e.triggerHandler("mediachanged", [this]); + }, this), 0); + } else { + // Let webrtc handle the rest. + setTimeout(_.bind(function() { + this.e.triggerHandler("mediasuccess", [this]); + }, this), this.delay); + } + + // Get notified of end events. + stream.onended = _.bind(function(event) { + console.log("User media stream ended."); + if (this.started) { + this.stop(); + } + }, this); + + // Set new stream. + this.localStream = stream; + this.e.triggerHandler("localstream", [stream, oldStream, this]); + + }; + UserMedia.prototype.stop = function() { this.started = false; @@ -186,6 +251,9 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ this.audioProcessor.disconnect() } this.audioLevel = 0; + this.audioMute = false; + this.videoMute = false; + this.mediaConstraints = null; console.log("Stopped user media."); this.e.triggerHandler("stopped", [this]); @@ -198,53 +266,97 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ UserMedia.prototype.applyAudioMute = function(mute) { - if (this.localStream) { + var m = !!mute; - var audioTracks = this.localStream.getAudioTracks(); - if (audioTracks.length === 0) { - //console.log('No local audio available.'); - return; - } + if (!enableRenegotiationSupport) { + + // Disable streams only - does not require renegotiation but keeps mic + // active and the stream will transmit silence. + + if (this.localStream) { + + var audioTracks = this.localStream.getAudioTracks(); + if (audioTracks.length === 0) { + //console.log('No local audio available.'); + return; + } + + for (var i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = !mute; + } + + if (mute) { + console.log("Local audio muted by disabling audio tracks."); + } else { + console.log("Local audio unmuted by enabling audio tracks."); + } - for (i = 0; i < audioTracks.length; i++) { - audioTracks[i].enabled = !mute; } - if (mute) { - console.log("Local audio muted.") + } else { + + // Remove audio stream, by creating a new stream and doing renegotiation. This + // is the way to go to disable the mic when audio is muted. + + if (this.localStream) { + if (this.audioMute !== m) { + this.audioMute = m; + this.doGetUserMediaWithConstraints(); + } } else { - console.log("Local audio unmuted.") + this.audioMute = m; } } - return mute; + return m; }; UserMedia.prototype.applyVideoMute = function(mute) { - if (this.localStream) { + var m = !!mute; - var videoTracks = this.localStream.getVideoTracks(); - if (videoTracks.length === 0) { - //console.log('No local video available.'); - return; - } + if (!enableRenegotiationSupport) { + + // Disable streams only - does not require renegotiation but keeps camera + // active and the stream will transmit black. + + if (this.localStream) { + var videoTracks = this.localStream.getVideoTracks(); + if (videoTracks.length === 0) { + //console.log('No local video available.'); + return; + } + + for (var i = 0; i < videoTracks.length; i++) { + videoTracks[i].enabled = !mute; + } + + if (mute) { + console.log("Local video muted by disabling video tracks."); + } else { + console.log("Local video unmuted by enabling video tracks."); + } - for (i = 0; i < videoTracks.length; i++) { - videoTracks[i].enabled = !mute; } + } else { - if (mute) { - console.log("Local video muted.") + // Removevideo stream, by creating a new stream and doing renegotiation. This + // is the way to go to disable the camera when video is muted. + + if (this.localStream) { + if (this.videoMute !== m) { + this.videoMute = m; + this.doGetUserMediaWithConstraints(); + } } else { - console.log("Local video unmuted.") + this.videoMute = m; } } - return mute; + return m; }; @@ -253,6 +365,13 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ console.log("Add usermedia stream to peer connection", pc, this.localStream); if (this.localStream) { pc.addStream(this.localStream); + var id = pc.id; + if (!peerconnections.hasOwnProperty(id)) { + peerconnections[id] = pc; + pc.currentcall.e.one("closed", function() { + delete peerconnections[id]; + }); + } } }; @@ -262,13 +381,15 @@ define(['jquery', 'underscore', 'audiocontext', 'webrtc.adapter'], function($, _ console.log("Remove usermedia stream from peer connection", pc, this.localStream); if (this.localStream) { pc.removeStream(this.localStream); + if (peerconnections.hasOwnProperty(pc.id)) { + delete peerconnections[pc.id]; + } } }; UserMedia.prototype.attachMediaStream = function(video) { - //console.log("attach", video, this.localStream); attachMediaStream(video, this.localStream); }; diff --git a/static/js/mediastream/webrtc.js b/static/js/mediastream/webrtc.js index bc23bbe7..97c7f069 100644 --- a/static/js/mediastream/webrtc.js +++ b/static/js/mediastream/webrtc.js @@ -117,6 +117,10 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u // Start always, no matter what. this.maybeStart(); }, this)); + this.usermedia.e.on("mediachanged", _.bind(function() { + // Propagate media change events. + this.e.triggerHandler("usermedia", [this.usermedia]); + }, this)); }; @@ -226,32 +230,36 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u switch (type) { case "Offer": - var busy = false; - var conference = null; - if (this.currentcall.from !== from) { + console.log("Offer process."); + if (this.settings.stereo) { + data.sdp = utils.addStereo(data.sdp); + } + targetcall = this.findTargetCall(from); + if (targetcall) { + // Hey we know this call. + targetcall.setRemoteDescription(new RTCSessionDescription(data), _.bind(function(sessionDescription, currentcall) { + if (currentcall === this.currentcall) { + // Main call. + this.e.triggerHandler("peercall", [this.currentcall]); + } + currentcall.createAnswer(_.bind(function(sessionDescription, currentcall) { + console.log("Sending answer", sessionDescription, currentcall.id); + this.api.sendAnswer(currentcall.id, sessionDescription); + }, this)); + }, this)); + } else { + // No target call. Check conference auto answer support. if (this.currentconference && this.currentconference.id === data._conference) { console.log("Received conference Offer -> auto.", from, data._conference); - conference = data._conference; - // clean own internal data before feeding into browser. + // Clean own internal data before feeding into browser. delete data._conference; - } else { - console.log("Received Offer from unknown id -> busy.", from, this.currentconference); - busy = true; + this.currentconference.autoAnswer(from, new RTCSessionDescription(data)); + break; } - } - if (busy) { + // Cannot do anything with this offer, reply with busy. + console.log("Received Offer from unknown id -> busy.", from); this.api.sendBye(from, "busy"); this.e.triggerHandler("busy", [from, to2, to]); - return; - } - console.log("Offer process."); - if (this.settings.stereo) { - data.sdp = utils.addStereo(data.sdp); - } - if (conference) { - this.currentconference.autoAnswer(from, new RTCSessionDescription(data)); - } else { - this.currentcall.setRemoteDescription(new RTCSessionDescription(data), _.bind(this.doAnswer, this)); } break; case "Candidate": @@ -280,7 +288,10 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u } // TODO(longsleep): In case of negotiation this could switch offer and answer // and result in a offer sdp sent as answer data. We need to handle this. - targetcall.setRemoteDescription(new RTCSessionDescription(data)); + targetcall.setRemoteDescription(new RTCSessionDescription(data), function() { + // Received remote description as answer. + console.log("Received answer after we sent offer", data); + }); break; case "Bye": targetcall = this.findTargetCall(from); @@ -425,16 +436,6 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u }; - WebRTC.prototype.doAnswer = function() { - - this.e.triggerHandler("peercall", [this.currentcall]); - this.currentcall.createAnswer(_.bind(function(sessionDescription, currentcall) { - console.log("Sending answer", sessionDescription, currentcall.id); - this.api.sendAnswer(currentcall.id, sessionDescription); - }, this)); - - }; - WebRTC.prototype.doXfer = function(id, token, options) { var registeredToken = tokens.get(token); @@ -482,12 +483,17 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u // Connect. xfer.setInitiate(true); - xfer.createPeerConnection(); + xfer.createPeerConnection(_.bind(function() { + xfer.e.on("negotiationNeeded", _.bind(function(event, currentxfer) { + this.sendOfferWhenNegotiationNeeded(currentxfer, id); + }, this)); + }, this)); + /* xfer.createOffer(_.bind(function(sessionDescription, currentxfer) { console.log("Sending xfer offer with sessionDescription", sessionDescription, currentxfer.id); // TODO(longsleep): Support sending this through data channel too if we have one. this.api.sendOffer(id, sessionDescription); - }, this)); + }, this));*/ }; @@ -553,12 +559,17 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u // Connect. peerscreenshare.setInitiate(true); //XXX(longsleep): This creates a data channel which is not needed. - peerscreenshare.createPeerConnection(); + peerscreenshare.createPeerConnection(_.bind(function() { + peerscreenshare.e.on("negotiationNeeded", _.bind(function(event, currentscreenshare) { + this.sendOfferWhenNegotiationNeeded(currentscreenshare, id); + }, this)); + }, this)); + /* peerscreenshare.createOffer(_.bind(function(sessionDescription, currentscreenshare) { console.log("Sending screen share offer with sessionDescription", sessionDescription, currentscreenshare.id); // TODO(longsleep): Support sending this through data channel too if we have one. this.api.sendOffer(id, sessionDescription); - }, this)); + }, this));*/ }; @@ -637,13 +648,16 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u } this.started = true; if (this.initiator) { - currentcall.createOffer(_.bind(function(sessionDescription, currentcall) { + /*currentcall.createOffer(_.bind(function(sessionDescription, currentcall) { console.log("Sending offer with sessionDescription", sessionDescription, currentcall.id); this.api.sendOffer(currentcall.id, sessionDescription); - }, this)); + }, this));*/ } else { this.calleeStart(); } + currentcall.e.on("negotiationNeeded", _.bind(function(event, currentcall) { + this.sendOfferWhenNegotiationNeeded(currentcall); + }, this)); }, this), _.bind(function() { // Error call. this.e.triggerHandler("error", ["Failed to create peer connection. See log for details."]); @@ -664,6 +678,22 @@ function($, _, PeerCall, PeerConference, PeerXfer, PeerScreenshare, UserMedia, u }; + WebRTC.prototype.sendOfferWhenNegotiationNeeded = function(currentcall, to) { + + // TODO(longsleep): Check if the check for stable is really required. + if (currentcall.peerconnection.pc.signalingState === "stable") { + if (!to) { + to = currentcall.id; + } + currentcall.createOffer(_.bind(function(sessionDescription, currentcall) { + console.log("Sending offer with sessionDescription", sessionDescription, to, currentcall); + // TODO(longsleep): Support sending this through data channel too if we have one. + this.api.sendOffer(to, sessionDescription); + }, this)); + } + + }; + WebRTC.prototype.onConnectionStateChange = function(iceConnectionState, currentcall) { // Defer this to allow native event handlers to complete before running more stuff. _.defer(_.bind(function() { diff --git a/static/js/services/api.js b/static/js/services/api.js new file mode 100644 index 00000000..cadd1cb3 --- /dev/null +++ b/static/js/services/api.js @@ -0,0 +1,27 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ + 'mediastream/api' +], function(Api) { + return ["globalContext", "connector", function(context, connector) { + return new Api(context.Cfg.Version, connector); + }]; +}); diff --git a/static/js/services/buddypicture.js b/static/js/services/buddypicture.js index 907ee489..b426ac46 100644 --- a/static/js/services/buddypicture.js +++ b/static/js/services/buddypicture.js @@ -22,7 +22,7 @@ define(['underscore'], function(underscore) { // buddyPicture - return ["mediaStream", "$window", function(mediaStream, $window) { + return ["$window", "restURL", function($window, restURL) { var buddyPicture = { @@ -38,7 +38,7 @@ } if (url.indexOf("img:") === 0) { - data.buddyPicture = data.buddyPictureLocalUrl = mediaStream.url.buddy(url.substr(4)); + data.buddyPicture = data.buddyPictureLocalUrl = restURL.buddy(url.substr(4)); } }, @@ -83,4 +83,4 @@ }]; - }); \ No newline at end of file + }); diff --git a/static/js/services/connector.js b/static/js/services/connector.js new file mode 100644 index 00000000..e475b052 --- /dev/null +++ b/static/js/services/connector.js @@ -0,0 +1,27 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ + 'mediastream/connector' +], function(Connector) { + return [function() { + return new Connector(); + }]; +}); diff --git a/static/js/services/mediastream.js b/static/js/services/mediastream.js index dc5ec571..238c6e26 100644 --- a/static/js/services/mediastream.js +++ b/static/js/services/mediastream.js @@ -23,29 +23,31 @@ define([ 'underscore', 'ua-parser', 'modernizr', - 'mediastream/connector', - 'mediastream/api', - 'mediastream/webrtc', 'mediastream/tokens' -], function($, _, uaparser, Modernizr, Connector, Api, WebRTC, tokens) { +], function($, _, uaparser, Modernizr, tokens) { - return ["globalContext", "$rootScope", "$route", "$location", "$window", "visibility", "alertify", "$http", "safeApply", "$timeout", "$sce", "localStorage", "continueConnector", function(context, $rootScope, $route, $location, $window, visibility, alertify, $http, safeApply, $timeout, $sce, localStorage, continueConnector) { + return ["globalContext", "connector", "api", "webrtc", "$rootScope", "$route", "$location", "$window", "visibility", "alertify", "$http", "safeApply", "$timeout", "$sce", "localStorage", "continueConnector", "restURL", function(context, connector, api, webrtc, $rootScope, $route, $location, $window, visibility, alertify, $http, safeApply, $timeout, $sce, localStorage, continueConnector, restURL) { var url = (context.Ssl ? "wss" : "ws") + "://" + context.Host + (context.Cfg.B || "/") + "ws"; - var version = context.Cfg.Version || "unknown"; + var version = context.Cfg.Version; console.log("Service version: " + version); console.log("Ws URL: " + url); console.log("Secure Contextual Escaping: " + $sce.isEnabled()); - var connector = new Connector(version); - var api = new Api(connector); - var webrtc = new WebRTC(api); var connectMarker = null; // Create encryption key from server token and browser name. var secureKey = sjcl.codec.base64.fromBits(sjcl.hash.sha256.hash(context.Cfg.Token + uaparser().browser.name)); - var authorizing = false; + + var authorizing = context.Cfg.UsersEnabled; + $rootScope.authorizing = function(value) { + // Boolean flag to indicate that an authentication is currently in progress. + if (typeof(value) !== "undefined") { + authorizing = !!value; + } + return authorizing; + }; var mediaStream = { version: version, @@ -55,21 +57,9 @@ define([ connector: connector, api: api, tokens: tokens, - url: { - room: function(id) { - id = $window.encodeURIComponent(id); - return $window.location.protocol + '//' + $window.location.host + context.Cfg.B + id; - }, - buddy: function(id) { - return $window.location.protocol + '//' + $window.location.host + context.Cfg.B + "static/img/buddy/s46/" + id; - }, - api: function(path) { - return (context.Cfg.B || "/") + "api/v1/" + path; - } - }, users: { register: function(form, success_cb, error_cb) { - var url = mediaStream.url.api("users"); + var url = restURL.api("users"); if (form) { // Form submit mode. $(form).attr("action", url).attr("method", "POST"); @@ -135,16 +125,9 @@ define([ }); } }, - authorizing: function(value) { - // Boolean flag to indicate that an authentication is currently in progress. - if (typeof(value) !== "undefined") { - authorizing = !!value; - } - return authorizing; - }, authorize: function(data, success_cb, error_cb) { - mediaStream.users.authorizing(true); - var url = mediaStream.url.api("sessions") + "/" + mediaStream.api.id + "/"; + $rootScope.authorizing(true); + var url = restURL.api("sessions") + "/" + mediaStream.api.id + "/"; var login = _.clone(data); login.id = mediaStream.api.id; login.sid = mediaStream.api.sid; @@ -160,14 +143,14 @@ define([ if (data.nonce !== "" && data.success) { success_cb(data, status); } else { - mediaStream.users.authorizing(false); + $rootScope.authorizing(false); if (error_cb) { error_cb(data, status); } } }). error(function(data, status) { - mediaStream.users.authorizing(false); + $rootScope.authorizing(false); if (error_cb) { error_cb(data, status) } @@ -226,40 +209,12 @@ define([ } }); }, - changeRoom: function(id, replace) { - id = $window.encodeURIComponent(id); - // Allow room ids to start with @,$ and + without quoting. - id = id.replace(/^%40/, "@"); - id = id.replace(/^%24/, "$"); - id = id.replace(/^%2B/, "+"); - safeApply($rootScope, function(scope) { - $location.path("/" + id); - if (replace) { - $location.replace(); - } - }); - return id; - }, - applyRoom: function() { - if (authorizing) { - // Do nothing while authorizing. - return; - } - var roomid = $rootScope.roomid; - if (roomid !== connector.roomid) { - console.log("Apply room", roomid); - connector.room(roomid); - } - }, initialize: function($rootScope, translation) { var cont = false; var ready = false; $rootScope.version = version; - $rootScope.roomid = null; - $rootScope.roomlink = null; - $rootScope.roomstatus = false; $rootScope.connect = false; var connect = function() { @@ -278,61 +233,10 @@ define([ } }; - var title = (function(e) { - return { - element: e, - text: e.text() - } - }($("title"))); - - // Room selector. - $rootScope.$on("$locationChangeSuccess", function(event) { - - var room; - if ($route.current) { - room = $route.current.params.room; - room = $window.decodeURIComponent(room); - } else { - room = ""; - } - console.info("Selected room is:", [room], ready, cont); - $rootScope.roomid = room; - - if (!ready || !cont) { - ready = true; - connect(); - } else { - // Auto apply room when already connected. - mediaStream.applyRoom(); - } - - $rootScope.roomlink = room ? mediaStream.url.room(room) : null; - if ($rootScope.roomlink) { - title.element.text(room + " - " + title.text); - } else { - title.element.text(title.text); - } - - }); - - // Cache events, to avoid ui flicker during quick room changes. - var roomStatusCache = $rootScope.roomstatus; - var roomCache = null; - var roomCache2 = null; - $rootScope.$on("roomStatus", function(event, status) { - // roomStatus is triggered by the buddylist when received.users. - roomStatusCache = status ? true : false; - roomCache = status ? $rootScope.roomid : null; - $timeout(function() { - if ($rootScope.roomstatus !== roomStatusCache) { - $rootScope.roomstatus = roomStatusCache; - } - if (roomCache !== roomCache2) { - // Let every one know about the new room. - $rootScope.$broadcast("room", roomCache); - roomCache2 = roomCache; - } - }, 100); + $rootScope.$on("rooms.ready", function(event) { + console.info("Initial room path set, continuing to connect ..."); + ready = true; + connect(); }); visibility.afterPrerendering(function() { @@ -356,7 +260,7 @@ define([ } }, prompt); }; - var url = mediaStream.url.api("tokens"); + var url = restURL.api("tokens"); var check = function(code) { $http({ method: "POST", @@ -406,9 +310,6 @@ define([ } }; - // For debugging. - $window.changeRoom = mediaStream.changeRoom; - return mediaStream; }]; diff --git a/static/js/services/resturl.js b/static/js/services/resturl.js new file mode 100644 index 00000000..fdc9e22d --- /dev/null +++ b/static/js/services/resturl.js @@ -0,0 +1,38 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ +], function() { + + return ["globalContext", "$window", function(context, $window) { + return { + room: function(id) { + id = $window.encodeURIComponent(id); + return $window.location.protocol + '//' + $window.location.host + context.Cfg.B + id; + }, + buddy: function(id) { + return $window.location.protocol + '//' + $window.location.host + context.Cfg.B + "static/img/buddy/s46/" + id; + }, + api: function(path) { + return (context.Cfg.B || "/") + "api/v1/" + path; + } + }; + }]; +}); diff --git a/static/js/services/roompin.js b/static/js/services/roompin.js new file mode 100644 index 00000000..b4490e05 --- /dev/null +++ b/static/js/services/roompin.js @@ -0,0 +1,58 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ +], function() { + + return ["$window", "$q", function($window, $q) { + var pinCache = {}; + var roompin = { + get: function(roomName) { + var cachedPIN = pinCache[roomName]; + return cachedPIN ? cachedPIN : null; + }, + clear: function(roomName) { + delete pinCache[roomName]; + console.log("Cleared PIN for", roomName); + }, + update: function(roomName, pin) { + if (pin) { + pinCache[roomName] = pin; + $window.alert("PIN for room " + roomName + " is now '" + pin + "'"); + } else { + roompin.clear(roomName); + $window.alert("PIN lock has been removed from room " + roomName); + } + }, + requestInteractively: function(roomName) { + var deferred = $q.defer(); + var pin = $window.prompt("Enter the PIN for " + roomName + " below"); + if (pin) { + pinCache[roomName] = pin; + deferred.resolve(); + } else { + deferred.reject(); + } + return deferred.promise; + } + }; + return roompin; + }]; +}); diff --git a/static/js/services/rooms.js b/static/js/services/rooms.js new file mode 100644 index 00000000..bae3d9c1 --- /dev/null +++ b/static/js/services/rooms.js @@ -0,0 +1,204 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ + 'angular', + 'jquery' +], function(angular, $) { + + return ["$window", "$location", "$timeout", "$q", "$route", "$rootScope", "$http", "globalContext", "safeApply", "connector", "api", "restURL", "roompin", function($window, $location, $timeout, $q, $route, $rootScope, $http, globalContext, safeApply, connector, api, restURL, roompin) { + var url = restURL.api("rooms"); + var requestedRoomName = ""; + var currentRoom = null; + + var joinFailed = function(error) { + setCurrentRoom(null); + + switch(error.Code) { + case "default_room_disabled": + rooms.randomRoom(); + break; + case "invalid_credentials": + roompin.clear(requestedRoomName); + /* falls through */ + case "authorization_required": + roompin.requestInteractively(requestedRoomName).then(joinRequestedRoom, + function() { + console.log("Authentication cancelled, try a different room"); + }); + break; + case "authorization_not_required": + roompin.clear(requestedRoomName); + joinRequestedRoom(); + break; + default: + console.log("Unknown error", error, "while joining room ", requestedRoomName); + break; + } + }; + + var joinRequestedRoom = function() { + if ($rootScope.authorizing()) { + // Do nothing while authorizing. + return; + } + + if (!connector.connected || !currentRoom || requestedRoomName !== currentRoom.Name) { + if (requestedRoomName !== "" || globalContext.Cfg.DefaultRoomEnabled) { + console.log("Joining room", requestedRoomName); + requestedRoomName = requestedRoomName ? requestedRoomName : ""; + api.sendHello(requestedRoomName, roompin.get(requestedRoomName), setCurrentRoom, joinFailed); + } else { + console.log("Default room disabled, requesting a random room."); + setCurrentRoom(null); + rooms.randomRoom(); + } + } + }; + + var setCurrentRoom = function(room) { + if (room === currentRoom) { + return; + } + var priorRoom = currentRoom; + currentRoom = room; + if (priorRoom) { + console.log("Left room", priorRoom.Name); + $rootScope.$broadcast("room.left", priorRoom.Name); + } + if (currentRoom) { + console.log("Joined room", currentRoom.Name); + $rootScope.$broadcast("room.joined", currentRoom.Name); + } + }; + + var updateRoom = function(room) { + var response = $q.defer(); + api.requestRoomUpdate(room, response.resolve, response.reject); + return response.promise.then(applyRoomUpdate); + }; + + var applyRoomUpdate = function(room) { + if (room.Credentials) { + roompin.update(currentRoom.Name, room.Credentials.PIN); + delete room.Credentials; + } + currentRoom = room; + $rootScope.$broadcast("room.updated", currentRoom); + return room; + }; + + connector.e.on("close error", function() { + setCurrentRoom(null); + }); + + api.e.on("received.self", function(event, data) { + joinRequestedRoom(); + }); + + api.e.on("received.room", function(event, room) { + applyRoomUpdate(room); + }); + + $rootScope.$on("authorization.succeeded", function() { + // NOTE(lcooper): This will have been skipped earlier, so try again. + joinRequestedRoom(); + }); + + $rootScope.$on("$locationChangeSuccess", function(event) { + var roomName; + if ($route.current) { + roomName = $route.current.params.room; + roomName = $window.decodeURIComponent(roomName); + } else { + roomName = ""; + } + + requestedRoomName = roomName; + if (connector.connected) { + joinRequestedRoom(); + } else { + $rootScope.$broadcast("rooms.ready"); + } + }); + + var rooms = { + inDefaultRoom: function() { + return (currentRoom !== null ? currentRoom.Name : requestedRoomName) === ""; + }, + randomRoom: function() { + $http({ + method: "POST", + url: url, + data: $.param({}), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }). + success(function(data, status) { + console.info("Retrieved random room data", data); + if (!data.name) { + data.name = ""; + } + $rootScope.$broadcast('room.random', {name: data.name}); + }). + error(function() { + console.error("Failed to retrieve random room data."); + $rootScope.$broadcast('room.random', {}); + }); + }, + joinByName: function(name, replace) { + name = $window.encodeURIComponent(name); + name = name.replace(/^%40/, "@"); + name = name.replace(/^%24/, "$"); + name = name.replace(/^%2B/, "+"); + + safeApply($rootScope, function(scope) { + $location.path("/" + name); + if (replace) { + $location.replace(); + } + }); + return name; + }, + link: function(room) { + var name = room ? room.Name : null; + if (!name) { + name = ""; + } + return restURL.room(name); + }, + setPIN: function(pin) { + pin = "" + pin; + var newRoom = angular.copy(currentRoom); + newRoom.Credentials = {PIN: pin}; + return updateRoom(newRoom).then(null, function(error) { + console.log("Failed to set room PIN", error); + return $q.reject(error); + }); + } + }; + + // NOTE(lcooper): For debugging only, do not use this on production. + $window.setRoomPIN = rooms.setPIN; + + return rooms; + }]; +}); diff --git a/static/js/services/services.js b/static/js/services/services.js index 0657fafe..b5a9975b 100644 --- a/static/js/services/services.js +++ b/static/js/services/services.js @@ -24,6 +24,9 @@ define([ 'services/desktopnotify', 'services/playsound', 'services/safeapply', + 'services/connector', + 'services/api', + 'services/webrtc', 'services/mediastream', 'services/appdata', 'services/buddydata', @@ -56,10 +59,16 @@ define([ 'services/continueconnector', 'services/chromeextension', 'services/usersettingsdata', - 'services/localstatus'], function(_, + 'services/localstatus', + 'services/rooms', + 'services/resturl', + 'services/roompin'], function(_, desktopNotify, playSound, safeApply, +connector, +api, +webrtc, mediaStream, appData, buddyData, @@ -92,12 +101,18 @@ screensharing, continueConnector, chromeExtension, userSettingsData, -localStatus) { +localStatus, +rooms, +restURL, +roompin) { var services = { desktopNotify: desktopNotify, playSound: playSound, safeApply: safeApply, + connector: connector, + api: api, + webrtc: webrtc, mediaStream: mediaStream, appData: appData, buddyData: buddyData, @@ -130,13 +145,16 @@ localStatus) { continueConnector: continueConnector, chromeExtension: chromeExtension, userSettingsData: userSettingsData, - localStatus: localStatus + localStatus: localStatus, + rooms: rooms, + restURL: restURL, + roompin: roompin }; var initialize = function(angModule) { _.each(services, function(service, name) { angModule.factory(name, service); - }) + }); }; return { diff --git a/static/js/services/videolayout.js b/static/js/services/videolayout.js index f53b5a1d..bf32084d 100644 --- a/static/js/services/videolayout.js +++ b/static/js/services/videolayout.js @@ -23,14 +23,14 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern var dynamicCSSContainer = "audiovideo-dynamic"; var renderers = {}; - var getRemoteVideoSize = function(videos, peers) { + var getRemoteVideoSize = function(videos, streams) { var size = { width: 1920, height: 1080 } if (videos.length) { if (videos.length === 1) { - var remoteVideo = peers[videos[0]].element.find("video").get(0); + var remoteVideo = streams[videos[0]].element.find("video").get(0); if (remoteVideo) { size.width = remoteVideo.videoWidth; size.height = remoteVideo.videoHeight; @@ -51,7 +51,7 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern OnePeople.prototype.name = "onepeople"; - OnePeople.prototype.render = function(container, size, scope, videos, peers) { + OnePeople.prototype.render = function(container, size, scope, videos, streams) { if (this.closed) { return; @@ -61,7 +61,7 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern var videoHeight; if (videos.length) { - var remoteSize = getRemoteVideoSize(videos, peers); + var remoteSize = getRemoteVideoSize(videos, streams); videoWidth = remoteSize.width; videoHeight = remoteSize.height; } @@ -235,25 +235,25 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern }; - ConferenceKiosk.prototype.render = function(container, size, scope, videos, peers) { + ConferenceKiosk.prototype.render = function(container, size, scope, videos, streams) { var big = this.big; if (big) { var currentbigpeerid = this.big.data("peerid"); - if (!peers[currentbigpeerid]) { + if (!streams[currentbigpeerid]) { console.log("Current big peer is no longer there", currentbigpeerid); this.big = big = null; } } if (!big) { if (videos.length) { - this.makeBig(peers[videos[0]].element); + this.makeBig(streams[videos[0]].element); this.bigVideo.style.opacity = 1; } } - var remoteSize = getRemoteVideoSize(videos, peers); + var remoteSize = getRemoteVideoSize(videos, streams); var aspectRatio = remoteSize.width / remoteSize.height; var innerHeight = size.height - 110; var innerWidth = size.width; @@ -304,18 +304,18 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern Classroom.prototype = Object.create(ConferenceKiosk.prototype); Classroom.prototype.constructor = Classroom; Classroom.prototype.name = "classroom"; - Classroom.prototype.render = function(container, size, scope, videos, peers) { + Classroom.prototype.render = function(container, size, scope, videos, streams) { var big = this.big; if (big) { var currentbigpeerid = this.big.data("peerid"); - if (!peers[currentbigpeerid]) { + if (!streams[currentbigpeerid]) { console.log("Current big peer is no longer there", currentbigpeerid); this.big = big = null; } } if (!big) { if (videos.length) { - this.makeBig(peers[videos[0]].element); + this.makeBig(streams[videos[0]].element); this.bigVideo.style.opacity = 1; } @@ -345,8 +345,8 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern return r; }; - var videos = _.keys(controller.peers); - var peers = controller.peers; + var videos = _.keys(controller.streams); + var streams = controller.streams; var container = scope.container; var layoutparent = scope.layoutparent; @@ -370,7 +370,7 @@ define(["jquery", "underscore", "modernizr", "injectCSS"], function($, _, Modern } } - return current.render(container, size, scope, videos, peers); + return current.render(container, size, scope, videos, streams); }, register: function(name, impl) { diff --git a/static/js/services/webrtc.js b/static/js/services/webrtc.js new file mode 100644 index 00000000..f54742d5 --- /dev/null +++ b/static/js/services/webrtc.js @@ -0,0 +1,27 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +define([ + 'mediastream/webrtc' +], function(WebRTC) { + return ["api", function(api) { + return new WebRTC(api); + }]; +}); diff --git a/static/partials/audiovideopeer.html b/static/partials/audiovideopeer.html index 4ccc80a4..f4b0e1a4 100644 --- a/static/partials/audiovideopeer.html +++ b/static/partials/audiovideopeer.html @@ -1,4 +1,4 @@ -
+
{{peerid|displayName}}
diff --git a/static/partials/chat.html b/static/partials/chat.html index bd850c32..da150439 100644 --- a/static/partials/chat.html +++ b/static/partials/chat.html @@ -5,12 +5,14 @@
{{_("Chat sessions")}}
diff --git a/static/partials/contactsmanager.html b/static/partials/contactsmanager.html index aaa71d15..cd8679e9 100644 --- a/static/partials/contactsmanager.html +++ b/static/partials/contactsmanager.html @@ -13,14 +13,18 @@ - diff --git a/static/partials/page/welcome.html b/static/partials/page/welcome.html index a28a6097..6ad5958f 100644 --- a/static/partials/page/welcome.html +++ b/static/partials/page/welcome.html @@ -1,4 +1,4 @@ -
+

{{_("Create a room and talk together")}}

@@ -6,13 +6,13 @@
- - + +

-

{{roomdata.link}}

+

{{roomdata.link}}

diff --git a/static/partials/roombar.html b/static/partials/roombar.html index 4f6ce8ec..e180adea 100644 --- a/static/partials/roombar.html +++ b/static/partials/roombar.html @@ -4,12 +4,12 @@
- +
- +
diff --git a/static/partials/usability.html b/static/partials/usability.html index 65d79300..8f57cd65 100644 --- a/static/partials/usability.html +++ b/static/partials/usability.html @@ -1,7 +1,6 @@
-
{{_("Checking camera and microphone access.")}}
{{_("Please allow access to your camera and microphone.")}}
{{_("Camera / microphone access required.")}}
+
{{contact.Userid|displayName}} - +
+ + +
+