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/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/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/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/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/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/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/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.")}}