diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -11,6 +11,8 @@ class OpenViduController extends Controller { + public const AUTH_HEADER = 'X-Meet-Auth-Token'; + /** * Accept the room join request. * @@ -102,6 +104,37 @@ ]); } + /** + * Create a connection for screen sharing. + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse + */ + public function createConnection($id) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $connection = $this->getConnectionFromRequest(); + + if ( + !$connection + || $connection->session_id != $room->session_id + || ($connection->role & Room::ROLE_PUBLISHER) == 0 + ) { + return $this->errorResponse(403); + } + + $response = $room->getSessionToken(Room::ROLE_SCREEN); + + return response()->json(['status' => 'success', 'token' => $response['token']]); + } + /** * Dismiss the participant/connection from the session. * @@ -132,7 +165,7 @@ } /** - * Listing of rooms that belong to the current user. + * Listing of rooms that belong to the authenticated user. * * @return \Illuminate\Http\JsonResponse */ @@ -289,12 +322,8 @@ return $this->errorResponse(500, \trans('meet.session-join-error')); } - // Create session token for screen sharing connection - if (($role & Room::ROLE_PUBLISHER) && !empty(request()->input('screenShare'))) { - $add_token = $room->getSessionToken(Room::ROLE_SCREEN); - - $response['shareToken'] = $add_token['token']; - } + // Get up-to-date connections metadata + $response['connections'] = $room->getSessionConnections(); // Get up-to-date connections metadata $response['connections'] = $room->getSessionConnections(); @@ -394,6 +423,18 @@ foreach (request()->input() as $key => $value) { switch ($key) { case 'role': + // The 'owner' role is not assignable + if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) { + return $this->errorResponse(403); + } elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) { + return $this->errorResponse(403); + } + + // The room owner has always a 'moderator' role + if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) { + $value |= Room::ROLE_MODERATOR; + } + $connection->{$key} = $value; break; } @@ -456,8 +497,38 @@ return true; } - // TODO: Moderators authentication + // Moderator's authentication via the extra request header + if ( + ($connection = $this->getConnectionFromRequest()) + && $connection->session_id === $room->session_id + && $connection->role & Room::ROLE_MODERATOR + ) { + return true; + } return false; } + + /** + * Get the connection object for the token in current request headers. + * It will also validate the token. + * + * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid) + */ + protected function getConnectionFromRequest() + { + // Authenticate the user via the extra request header + if ($token = request()->header(self::AUTH_HEADER)) { + list($connId, ) = explode(':', base64_decode($token), 2); + + if ( + ($connection = Connection::find($connId)) + && $connection->metadata['authToken'] === $token + ) { + return $connection; + } + } + + return null; + } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -230,6 +230,8 @@ if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); + $authToken = base64_encode($json['id'] . ':' . \random_bytes(16)); + // Extract the 'token' part of the token, it will be used to authenticate the connection. // It will be needed in next iterations e.g. to authenticate moderators that aren't // Kolab4 users (or are just not logged in to Kolab4). @@ -242,12 +244,13 @@ $conn->session_id = $this->session_id; $conn->room_id = $this->id; $conn->role = $role; - $conn->metadata = ['token' => $url['token']]; + $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken]; $conn->save(); return [ 'session' => $this->session_id, 'token' => $json['token'], + 'authToken' => $authToken, 'connectionId' => $json['id'], 'role' => $role, ]; diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -44,7 +44,6 @@ let subscribersContainer OV = new OpenVidu() - screenOV = new OpenVidu() // If there's anything to do, do it here. //OV.setAdvancedConfiguration(config) @@ -131,11 +130,14 @@ }) session.on('connectionDestroyed', event => { - let conn = connections[event.connection.connectionId] + let connectionId = event.connection.connectionId + let conn = connections[connectionId] + if (conn) { $(conn.element).remove() - delete connections[event.connection.connectionId] + delete connections[connectionId] } + resize() }) @@ -689,13 +691,16 @@ */ function switchScreen(callback) { if (screenPublisher) { + // Note: This is what the original openvidu-call app does. + // It is probably better for performance reasons to close the connection, + // than to use unpublish() and keep the connection open. screenSession.disconnect() screenSession = null screenPublisher = null if (callback) { - // Note: Disconnecting invalidates the token. The callback should request - // a new token for the next screen sharing session. + // Note: Disconnecting invalidates the token, we have to inform the vue component + // to update UI state (and be prepared to request a new token). callback(false) } @@ -721,7 +726,9 @@ // It's me if (session.connection.connectionId == data.connectionId) { const rolePublisher = data.role && data.role & Roles.PUBLISHER + const roleModerator = data.role && data.role & Roles.MODERATOR const isPublisher = sessionData.role & Roles.PUBLISHER + const isModerator = sessionData.role & Roles.MODERATOR // Inform the vue component, so it can update some UI controls let update = () => { @@ -746,6 +753,17 @@ // update the participant element sessionData.element = participantUpdate(sessionData.element, sessionData) + // promoted/demoted to/from a moderator + if ('role' in data) { + if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) { + // Update all participants, to enable/disable the popup menu + Object.keys(connections).forEach(key => { + const conn = connections[key] + participantUpdate(conn.element, conn) + }) + } + } + // promoted to a publisher if ('role' in data && !isPublisher && rolePublisher) { publisher.createVideoElement(sessionData.element, 'PREPEND') @@ -894,18 +912,19 @@ const element = $(wrapper) const isModerator = sessionData.role & Roles.MODERATOR const isSelf = session.connection.connectionId == params.connectionId + const rolePublisher = params.role & Roles.PUBLISHER + const roleModerator = params.role & Roles.MODERATOR + const roleScreen = params.role & Roles.SCREEN + const roleOwner = params.role & Roles.OWNER // Handle publisher-to-subscriber and subscriber-to-publisher change - if ('role' in params && !(params.role & Roles.SCREEN)) { - const rolePublisher = params.role & Roles.PUBLISHER + if (!roleScreen) { const isPublisher = element.is('.meet-video') if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) { element.remove() return participantCreate(params) } - - element.find('.action-role-publisher input').prop('checked', params.role & Roles.PUBLISHER) } if ('audioActive' in params) { @@ -928,13 +947,24 @@ element.addClass('moderated') } - element.find('.dropdown-menu')[isSelf || isModerator ? 'removeClass' : 'addClass']('hidden') - element.find('.permissions')[isModerator ? 'removeClass' : 'addClass']('hidden') + const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf); + const withMenu = isSelf || (isModerator && !roleOwner) - if ('role' in params && params.role & Roles.SCREEN) { - element.find('.permissions').addClass('hidden') + let elements = { + '.dropdown-menu': withMenu, + '.permissions': withPerm, + 'svg.moderator': roleModerator, + 'svg.user': !roleModerator } + Object.keys(elements).forEach(key => { + element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden') + }) + + element.find('.action-role-publisher input').prop('checked', rolePublisher) + element.find('.action-role-moderator input').prop('checked', roleModerator) + .prop('disabled', roleOwner) + return wrapper } @@ -965,7 +995,10 @@ '' + '' @@ -1060,6 +1093,50 @@ }) } + let connectionRole = () => { + if (params.isSelf) { + return sessionData.role + } + if (params.connectionId in connections) { + return connections[params.connectionId].role + } + return 0 + } + + // Don't close the menu on permission change + element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() }) + + if (sessionData.onConnectionChange) { + element.find('.action-role-publisher input').on('change', e => { + const enabled = e.target.checked + let role = connectionRole() + + if (enabled) { + role |= Roles.PUBLISHER + } else { + role |= Roles.SUBSCRIBER + if (role & Roles.PUBLISHER) { + role ^= Roles.PUBLISHER + } + } + + sessionData.onConnectionChange(params.connectionId, { role }) + }) + + element.find('.action-role-moderator input').on('change', e => { + const enabled = e.target.checked + let role = connectionRole() + + if (enabled) { + role |= Roles.MODERATOR + } else if (role & Roles.MODERATOR) { + role ^= Roles.MODERATOR + } + + sessionData.onConnectionChange(params.connectionId, { role }) + }) + } + return element.get(0) } @@ -1163,6 +1240,10 @@ let gotSession = !!screenSession + if (!screenOV) { + screenOV = new OpenVidu() + } + // Init screen sharing session if (!gotSession) { screenSession = screenOV.initSession(); @@ -1170,6 +1251,13 @@ let successFunc = function() { screenSession.publish(screenPublisher) + + screenSession.on('sessionDisconnected', event => { + callback(false) + screenSession = null + screenPublisher = null + }) + if (callback) { callback(true) } @@ -1178,7 +1266,7 @@ let errorFunc = function() { screenPublisher = null if (callback) { - callback(false) + callback(false, true) } } diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -293,6 +293,10 @@ } } +#logon-form { + flex-basis: auto; // Bootstrap issue? See logon page with width < 992 +} + #logon-form-footer { a:not(:first-child) { margin-left: 2em; diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -174,6 +174,7 @@ faAlignLeft, faCog, faCompress, + faCrown, faDesktop, faExpand, faMicrophone, @@ -189,6 +190,7 @@ faAlignLeft, faCog, faCompress, + faCrown, faDesktop, faExpand, faMicrophone, @@ -200,6 +202,7 @@ ) let roomRequest + const authHeader = 'X-Meet-Auth-Token' export default { components: { @@ -253,6 +256,8 @@ if (this.meet) { this.meet.leaveRoom() } + + delete axios.defaults.headers.common[authHeader] }, methods: { authSuccess() { @@ -298,6 +303,10 @@ if (init) { this.joinSession() } + + if (this.session.authToken) { + axios.defaults.headers.common[authHeader] = this.session.authToken + } }) .catch(error => { if (!error.response) { @@ -616,23 +625,26 @@ this.setMenuItem('video', enabled) }, switchScreen() { - this.meet.switchScreen(enabled => { - this.setMenuItem('screen', enabled) - - // After one screen sharing session ended request a new token - // for the next screen sharing session - if (!enabled) { - // TODO: This might need to be a different route. E.g. the room password might have - // changed since user joined the session - // Also because it creates a redundant connection (token) - axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) - .then(response => { - // Response data contains: session, token and shareToken - this.session.shareToken = response.data.shareToken - this.meet.updateSession(this.session) - }) - } - }) + const switchScreenAction = () => { + this.meet.switchScreen((enabled, error) => { + this.setMenuItem('screen', enabled) + if (!enabled && !error) { + // Closing a screen sharing connection invalidates the token + delete this.session.shareToken + } + }) + } + + if (this.session.shareToken || !$('#meet-session-menu').find('.link-screen').is('.text-danger')) { + switchScreenAction() + } else { + axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections') + .then(response => { + this.session.shareToken = response.data.token + this.meet.updateSession(this.session) + switchScreenAction() + }) + } }, updateParticipant(connId, params) { if (this.isModerator()) { diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -84,6 +84,7 @@ Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); + // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); @@ -100,6 +101,12 @@ ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); + Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); + // FIXME: I'm not sure about this one, should we use DELETE request maybe? + Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); + Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); + Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); + Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php --- a/src/tests/Browser/Meet/RoomControlsTest.php +++ b/src/tests/Browser/Meet/RoomControlsTest.php @@ -273,6 +273,7 @@ ->assertElementsCount('@chat-list .message', 0) ->keys('@chat-input', 'test1', '{enter}') ->assertValue('@chat-input', '') + ->waitFor('@chat-list .message') ->assertElementsCount('@chat-list .message', 1) ->assertSeeIn('@chat-list .message .nickname', 'john') ->assertSeeIn('@chat-list .message div:last-child', 'test1'); diff --git a/src/tests/Browser/Meet/RoomModeratorTest.php b/src/tests/Browser/Meet/RoomModeratorTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Meet/RoomModeratorTest.php @@ -0,0 +1,185 @@ +clearMeetEntitlements(); + $this->assignMeetEntitlement('john@kolab.org'); + + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + } + + public function tearDown(): void + { + $this->clearMeetEntitlements(); + + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + + parent::tearDown(); + } + + /** + * Test three users in a room, one will be promoted/demoted to/from a moderator + * + * @group openvidu + */ + public function testModeratorPromotion(): void + { + $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) { + // In one browser window join as a room owner + $browser->visit(new RoomPage('john')) + ->click('@setup-button') + ->submitLogon('john@kolab.org', 'simple123') + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->type('@setup-nickname-input', 'John') + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // In one browser window join as a guest (to be promoted) + $guest1->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertMissing('@setup-status-message') + ->assertSeeIn('@setup-button', "JOIN") + ->type('@setup-nickname-input', 'Guest1') + // Join the room, disable cam/mic + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // In one browser window join as a guest + $guest2->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertMissing('@setup-status-message') + ->assertSeeIn('@setup-button', "JOIN") + // Join the room, disable mic + ->select('@setup-mic-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // Assert that only the owner is a moderator right now + $guest1->waitFor('@session video') + ->assertMissing('@session div.meet-video .meet-nickname') // guest2 + ->assertVisible('@session div.meet-subscriber.self svg.user') // self + ->assertMissing('@session div.meet-subscriber.self svg.moderator') // self + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }) + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') + ->assertMissing('.dropdown-menu'); + + $guest2->waitFor('@session video') + ->assertVisible('@session div.meet-video svg.user') // self + ->assertMissing('@session div.meet-video svg.moderator'); // self + /* + it does not work because the order is different all the time + + ->assertMissing('@session div.meet-subscriber:nth-child(1) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:nth-child(1) svg.moderator') // owner + ->assertVisible('@session div.meet-subscriber:nth-child(2) svg.user') // guest1 + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); // guest1 + */ + + // Promote guest1 to a moderator + $browser->waitFor('@session video') + ->assertMissing('@session div.meet-subscriber.self svg.user') // self + ->assertVisible('@session div.meet-subscriber.self svg.moderator') // self + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertChecked('.action-role-moderator input') + ->assertDisabled('.action-role-moderator input'); + }) + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') + ->whenAvailable('@session div.meet-subscriber:not(.self) .dropdown-menu', function (Browser $browser) { + $browser->assertNotChecked('.action-role-moderator input') + ->click('.action-role-moderator input'); + }); + + // Assert that we have two moderators now + $guest2->waitFor('@session div.meet-subscriber:nth-child(2) svg.moderator') + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.user'); // guest1 + + $guest1->waitFor('@session div.meet-subscriber.self svg.moderator') + ->assertMissing('@session div.meet-subscriber.self svg.user') // self + ->assertVisible('@session div.meet-video svg.user') // guest2 + ->assertMissing('@session div.meet-video svg.moderator') // guest2 + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') // owner + ->assertMissing('@session div.meet-subscriber:not(.self) .dropdown-menu') + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) { + $browser->assertChecked('.action-role-moderator input') + ->assertEnabled('.action-role-moderator input') + ->assertNotChecked('.action-role-publisher input') + ->assertEnabled('.action-role-publisher input'); + }); + + $browser->waitFor('@session div.meet-subscriber:not(.self) svg.moderator') + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user'); + + // Check if a moderator can unpublish another user + $guest1->click('@session div.meet-video .meet-nickname') + ->whenAvailable('@session div.meet-video .dropdown-menu', function (Browser $browser) { + $browser->assertNotChecked('.action-role-moderator input') + ->assertEnabled('.action-role-moderator input') + ->assertChecked('.action-role-publisher input') + ->assertEnabled('.action-role-publisher input') + ->click('.action-role-publisher input'); + }) + ->waitUntilMissing('@session div.meet-video'); + + $guest2->waitUntilMissing('@session div.meet-video'); + + // Demote guest1 back to a normal user + $browser->waitFor('@session div.meet-subscriber:nth-child(3)') + ->click('@session') // somehow needed to make the next line invoke the menu + ->click('@session div.meet-subscriber:nth-child(2) .meet-nickname') + ->whenAvailable('@session div.meet-subscriber:nth-child(2) .dropdown-menu', function ($browser) { + $browser->assertChecked('.action-role-moderator input') + ->click('.action-role-moderator input'); + }) + ->waitFor('@session div.meet-subscriber:nth-child(2) svg.user') + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); + + $guest1->waitFor('@session div.meet-subscriber.self svg.user') + ->assertMissing('@session div.meet-subscriber.self svg.moderator') + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }); + }); + } +} diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -74,7 +74,7 @@ $room->save(); } - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('john')) @@ -130,7 +130,7 @@ */ public function testTwoUsersInARoom(): void { - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser, Browser $guest) { // In one browser window act as a guest @@ -293,7 +293,7 @@ */ public function testSubscribers(): void { - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser, Browser $guest) { // Join the room as the owner diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php --- a/src/tests/Feature/Controller/OpenViduTest.php +++ b/src/tests/Feature/Controller/OpenViduTest.php @@ -119,7 +119,6 @@ $this->assertSame($session_id, $json['session']); $this->assertTrue(is_string($session_id) && !empty($session_id)); $this->assertTrue(strpos($json['token'], 'wss://') === 0); - $this->assertTrue(!array_key_exists('shareToken', $json)); $john_token = $json['token']; @@ -131,7 +130,6 @@ $this->assertSame(322, $json['code']); $this->assertTrue(empty($json['token'])); - $this->assertTrue(empty($json['shareToken'])); // Non-owner, now the session exists, with 'init', but no 'canPublish' argument $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); @@ -143,7 +141,6 @@ $this->assertSame($session_id, $json['session']); $this->assertTrue(strpos($json['token'], 'wss://') === 0); $this->assertTrue($json['token'] != $john_token); - $this->assertTrue(empty($json['shareToken'])); // Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER' $post = ['canPublish' => true, 'init' => 1]; @@ -156,7 +153,6 @@ $this->assertSame($session_id, $json['session']); $this->assertTrue(strpos($json['token'], 'wss://') === 0); $this->assertTrue($json['token'] != $john_token); - $this->assertTrue(!array_key_exists('shareToken', $json)); $this->assertEmpty($json['config']['password']); $this->assertEmpty($json['config']['requires_password']); @@ -332,6 +328,7 @@ $this->assertTrue(strpos($json['token'], 'wss://') === 0); // TODO: Test a scenario where both password and lock are enabled + // TODO: Test accepting/denying as a non-owner moderator } /** @@ -358,8 +355,6 @@ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']); $this->assertSame($room->session_id, $json['session']); $this->assertTrue(strpos($json['token'], 'wss://') === 0); - $this->assertTrue(strpos($json['shareToken'], 'wss://') === 0); - $this->assertTrue($json['shareToken'] != $json['token']); } /** @@ -415,6 +410,76 @@ $this->assertCount(2, $json); } + /** + * Test creating an extra connection for screen sharing + * + * @group openvidu + */ + public function testCreateConnection(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $room = Room::where('name', 'john')->first(); + $room->session_id = null; + $room->save(); + + $this->assignMeetEntitlement($john); + + // First we create the session + $post = ['init' => 1, 'canPublish' => 1]; + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(200); + + $json = $response->json(); + $owner_auth_token = $json['authToken']; + + // And the other user connection + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + $conn_id = $json['connectionId']; + $auth_token = $json['authToken']; + + // Non-existing room name + $response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []); + $response->assertStatus(404); + + // No connection token provided + $response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []); + $response->assertStatus(403); + + // Invalid token + $response = $this->actingAs($jack) + ->withHeaders([OpenViduController::AUTH_HEADER => '123']) + ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); + + $response->assertStatus(403); + + // Subscriber can't get the screen-sharing connection + // Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user + // throughout the test + $response = $this->actingAs($jack) + ->withHeaders([OpenViduController::AUTH_HEADER => $auth_token]) + ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); + + $response->assertStatus(403); + + // Publisher can get the connection + $response = $this->actingAs($jack) + ->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token]) + ->post("api/v4/openvidu/rooms/{$room->name}/connections", []); + + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertTrue(strpos($json['token'], 'wss://') === 0); + $this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false); + } + /** * Test dismissing a participant (closing a connection) * @@ -476,6 +541,20 @@ $this->assertSame('success', $json['status']); $this->assertNull($room->getOVConnection($conn_id)); + + // Test acting as a moderator + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + $json = $response->json(); + $conn_id = $json['connectionId']; + + // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user + // throughout the test + $response = $this->actingAs($jack) + ->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)]) + ->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss"); + + $response->assertStatus(200); } /** @@ -576,6 +655,7 @@ $response->assertStatus(200); $json = $response->json(); + $owner_conn_id = $json['connectionId']; // And the other user connection $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); @@ -584,6 +664,7 @@ $json = $response->json(); $conn_id = $json['connectionId']; + $auth_token = $json['authToken']; $room->refresh(); $conn_data = $room->getOVConnection($conn_id); @@ -616,5 +697,60 @@ $this->assertSame('success', $json['status']); $this->assertSame($post['role'], Connection::find($conn_id)->role); + + // Access as moderator + // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user + // throughout the test + $token = $this->getModeratorToken($room); + $post = ['role' => Room::ROLE_PUBLISHER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame($post['role'], Connection::find($conn_id)->role); + + // Assert that it's not possible to add/remove the 'owner' role + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + + $response->assertStatus(403); + + $post = ['role' => Room::ROLE_PUBLISHER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); + + $response->assertStatus(403); + + // Assert that removing a 'moderator' role from the owner is not possible + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); + + $response->assertStatus(200); + + $this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role); + + // Assert that non-moderator token does not allow access + $post = ['role' => Room::ROLE_SUBSCRIBER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + + $response->assertStatus(403); + } + + /** + * Create a moderator connection to the room session. + * + * @param \App\OpenVidu\Room $room The room + * + * @return string The connection authentication token + */ + private function getModeratorToken(Room $room): string + { + $result = $room->getSessionToken(Room::ROLE_MODERATOR); + + return $result['authToken']; } }