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,68 @@ class OpenViduController extends Controller { /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function acceptJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestAccept($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-accept-error')); + } + + return response()->json(['status' => 'success']); + } + + /** + * Denying the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function denyJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestDeny($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-deny-error')); + } + + return response()->json(['status' => 'success']); + } + + /** * Close the room session. * * @param string $id Room identifier (name) @@ -44,6 +106,37 @@ } /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $conn Connection identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function dismissConnection($id, $conn) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->closeOVConnection($conn)) { + return $this->errorResponse(500, \trans('meet.session-dismiss-connection-error')); + } + + return response()->json(['status' => 'success']); + } + + /** * Listing of rooms that belong to the current user. * * @return \Illuminate\Http\JsonResponse @@ -136,19 +229,53 @@ if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { - // Note: We send the config to the client so it knows to display the password field - $response = [ - 'config' => $config, - 'message' => \trans('meet.session-password-error'), - 'status' => 'error', - ]; - - return response()->json($response, 425); + return $this->errorResponse(425, \trans('meet.session-password-error'), ['config' => $config]); + } + } + + // Handle locked room + if (!$isOwner && $config['locked']) { + $nickname = request()->input('nickname'); + $picture = request()->input('picture'); + $requestId = request()->input('requestId'); + + $request = $requestId ? $room->requestGet($requestId) : null; + + $error = \trans('meet.session-room-locked-error'); + + // Request already has been processed (not accepted yet, but it could be denied) + if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { + if (!$request) { + if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + if (empty($picture)) { + $svg = file_get_contents(resource_path('images/user.svg')); + $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); + } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // TODO: Resize when big/make safe the user picture? + + $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; + + if (!$room->requestSave($requestId, $request)) { + // FIXME: should we use error code 500? + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // Send the request (signal) to the owner + $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); + } + + return $this->errorResponse(427, $error, ['config' => $config]); } } // Create session token for the current user/connection - $response = $room->getSessionToken('PUBLISHER'); + $response = $room->getSessionToken($isOwner ? Room::ROLE_MODERATOR : Room::ROLE_PUBLISHER); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); @@ -156,7 +283,7 @@ // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { - $add_token = $room->getSessionToken('PUBLISHER'); + $add_token = $room->getSessionToken(Room::ROLE_PUBLISHER); $response['shareToken'] = $add_token['token']; } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -19,10 +19,11 @@ * * @param int $code Error code * @param string $message Error message + * @param array $data Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function errorResponse(int $code, string $message = null) + protected function errorResponse(int $code, string $message = null, array $data = []) { $errors = [ 400 => "Bad request", @@ -39,6 +40,10 @@ 'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"), ]; + if (!empty($data)) { + $response = $response + $data; + } + return response()->json($response, $code); } } 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 @@ -4,6 +4,7 @@ use App\Traits\SettingsTrait; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; /** * The eloquent definition of a Room. @@ -17,6 +18,13 @@ { use SettingsTrait; + public const ROLE_MODERATOR = 'MODERATOR'; + public const ROLE_PUBLISHER = 'PUBLISHER'; + public const ROLE_SUBSCRIBER = 'SUBSCRIBER'; + + public const REQUEST_ACCEPTED = 'accepted'; + public const REQUEST_DENIED = 'denied'; + protected $fillable = [ 'user_id', 'name' @@ -53,6 +61,54 @@ } /** + * Destroy a OpenVidu connection + * + * @param string $conn Connection identifier + * + * @return bool True on success, False otherwise + * @throws \Exception if session does not exist + */ + public function closeOVConnection($conn): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('DELETE', $url); + + return $response->getStatusCode() == 204; + } + + /** + * Fetch a OpenVidu connection information. + * + * @param string $conn Connection identifier + * + * @return ?array Connection data on success, Null otherwise + * @throws \Exception if session does not exist + */ + public function getOVConnection($conn): ?array + { + // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists + // TODO: Maybe use some other name? getParticipant? + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('GET', $url); + + if ($response->getStatusCode() == 200) { + return json_decode($response->getBody(), true); + } + + return null; + } + + /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise @@ -73,6 +129,7 @@ if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); + return null; } $session = json_decode($response->getBody(), true); @@ -114,7 +171,7 @@ * * @return array|null Token data on success, NULL otherwise */ - public function getSessionToken($role = 'PUBLISHER'): ?array + public function getSessionToken($role = self::ROLE_PUBLISHER): ?array { $response = $this->client()->request( 'POST', @@ -163,6 +220,75 @@ } /** + * Accept the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestAccept(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_ACCEPTED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Deny the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestDeny(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_DENIED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Get the join request data. + * + * @param string $id Request identifier + * + * @return array|null Request data (e.g. nickname, status, picture?) + */ + public function requestGet(string $id): ?array + { + return Cache::get($this->session_id . '-' . $id); + } + + /** + * Save the join request. + * + * @param string $id Request identifier + * @param array $request Request data + * + * @return bool True on success, False on failure + */ + public function requestSave(string $id, array $request): bool + { + // We don't really need the picture in the cache + // As we use this cache for the request status only + unset($request['picture']); + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany @@ -171,4 +297,65 @@ { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } + + /** + * Send a OpenVidu signal to the session participants (connections) + * + * @param string $name Signal name (type) + * @param array $data Signal data array + * @param array|string $target List of target connections, Null for all connections. + * It can be also a participant role. + * + * @return bool True on success, False on failure + * @throws \Exception if session does not exist + */ + public function signal(string $name, array $data = [], $target = null): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $post = [ + 'session' => $this->session_id, + 'type' => $name, + 'data' => $data ? json_encode($data) : '', + ]; + + // Get connection IDs by participant role + if (is_string($target)) { + // TODO: We should probably store this in our database/redis. I foresee a use-case + // for such a connections store on our side, e.g. keeping participant + // metadata, e.g. selected language, extra roles like a "language interpreter", etc. + + $response = $this->client()->request('GET', 'sessions/' . $this->session_id); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $json = json_decode($response->getBody(), true); + $connections = []; + + foreach ($json['connections']['content'] as $connection) { + if ($connection['role'] === $target) { + $connections[] = $connection['id']; + break; + } + } + + if (empty($connections)) { + return false; + } + + $target = $connections; + } + + if (!empty($target)) { + $post['to'] = $target; + } + + $response = $this->client()->request('POST', 'signal', ['json' => $post]); + + return $response->getStatusCode() == 200; + } } diff --git a/src/resources/images/user.svg b/src/resources/images/user.svg new file mode 100644 --- /dev/null +++ b/src/resources/images/user.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 @@ -14,6 +14,7 @@ let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata + let role // Current user role let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing @@ -70,7 +71,7 @@ * Join the room session * * @param data Session metadata and event handlers (session, token, shareToken, nickname, - * chatElement, menuElement, onDestroy) + * chatElement, menuElement, onDestroy, onJoinRequest) */ function joinRoom(data) { resize(); @@ -90,6 +91,7 @@ session.on('connectionCreated', event => { // Ignore the current user connection if (event.connection.role) { + role = event.connection.role return } @@ -105,6 +107,7 @@ let connectionId = event.connection.connectionId let metadata = JSON.parse(event.connection.data) + metadata.connId = connectionId let wrapper = videoWrapperCreate(container, metadata) connections[connectionId] = { @@ -414,7 +417,7 @@ */ function signalEventHandler(signal) { let conn, data - let connId = signal.from.connectionId + let connId = signal.from ? signal.from.connectionId : null switch (signal.type) { case 'signal:userChanged': @@ -431,6 +434,12 @@ data.id = connId pushChatMessage(data) break + + case 'signal:joinRequest': + if (sessionData.onJoinRequest) { + sessionData.onJoinRequest(JSON.parse(signal.data)) + } + break; } } @@ -645,26 +654,31 @@ function videoWrapperCreate(container, params) { // Create the element let wrapper = $('
').html( - `${svgIcon("user", 'fas', 'watermark')} -
- - -
-
- - - -
-
- - -
` + svgIcon('user', 'fas', 'watermark') + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' ) if (params.publisher) { // Add events for nickname change let nickname = wrapper.addClass('publisher').find('.nickname') - let editable = nickname.find('span').get(0) + let editable = nickname.find('.content')[0] let editableEnable = () => { editable.contentEditable = true editable.focus() @@ -688,14 +702,24 @@ } }) } else { - wrapper.find('.nickname > svg').addClass('hidden') - wrapper.find('.link-audio').removeClass('hidden') .on('click', e => { let video = wrapper.find('video')[0] video.muted = !video.muted wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger') }) + + if (role == 'MODERATOR') { + wrapper.addClass('moderated') + + wrapper.find('.nickname').attr({title: 'Options', 'data-toggle': 'dropdown'}).dropdown() + + wrapper.find('.action-dismiss').on('click', e => { + if (sessionData.onDismiss) { + sessionData.onDismiss(params.connId) + } + }) + } } videoWrapperUpdate(wrapper, params) @@ -741,7 +765,7 @@ } if ('nickname' in params) { - $(wrapper).find('.nickname > span').text(params.nickname) + $(wrapper).find('.nickname > .content').text(params.nickname) } } diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php --- a/src/resources/lang/en/meet.php +++ b/src/resources/lang/en/meet.php @@ -21,6 +21,9 @@ 'session-join-error' => 'Failed to join the session.', 'session-close-error' => 'Failed to close the session.', 'session-close-success' => 'The session has been closed successfully.', + 'session-dismiss-connection-error' => 'Failed to dismiss the connection.', 'session-password-error' => 'Failed to join the session. Invalid password.', - + 'session-request-accept-error' => 'Failed to accept the join request.', + 'session-request-deny-error' => 'Failed to deny the join request.', + 'session-room-locked-error' => 'Failed to join the session. Room locked.', ]; diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -6,12 +6,6 @@ align-items: center; justify-content: center; - .watermark { - color: darken($menu-bg-color, 20%); - width: 50%; - height: 50%; - } - video { // To make object-fit:cover working we have to set the height in pixels // on the wrapper element. This is what javascript method will do. @@ -33,6 +27,19 @@ } } + .watermark { + color: darken($menu-bg-color, 20%); + width: 50%; + height: 50%; + } + + .dropdown { + position: absolute; + top: 0; + left: 0; + right: 0; + } + .controls { position: absolute; bottom: 0; @@ -78,41 +85,53 @@ border-radius: 1em; max-width: calc(100% - 1em); background: rgba(#fff, 0.8); + color: $body-color; + text-decoration: none !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; - button { + .icon { display: none; + width: 2em; + margin: 0 -1em; } - span { + .content { outline: none; } } + &.moderated .nickname { + display: flex; + + .content { + order: 1; + + &:not(:empty) + .icon { + margin-right: 0; + } + } + + .icon { + display: inline-block; + } + } + &.publisher .nickname { - cursor: pointer; background: rgba($main-color, 0.9); &:focus-within { box-shadow: $btn-focus-box-shadow; } - span:empty { + .content:empty { display: block; height: 2em; - &:not(:focus) + button { - display: block; - position: absolute; - top: 0; - left: 0; - width: 2em; - height: 2em; - border-radius: 50%; - padding: 0; - color: $menu-gray; + &:not(:focus) + .icon { + display: inline-block; } } } @@ -253,3 +272,29 @@ } } } + +.toast.join-request { + .toast-header { + color: #eee; + } + + .toast-body { + display: flex; + } + + .picture { + margin-right: 1em; + + img { + width: 64px; + height: 64px; + border: 1px solid #555; + border-radius: 50%; + object-fit: cover; + } + } + + .content { + flex: 1; + } +} diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss --- a/src/resources/themes/toast.scss +++ b/src/resources/themes/toast.scss @@ -4,6 +4,10 @@ right: 0; margin: 0.5rem; width: 320px; + max-height: calc(100% - 1rem); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(52, 58, 64, 0.95) transparent; z-index: 1055; // above Bootstrap's modal backdrop and dialogs @media (max-width: 375px) { 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 @@ -70,16 +70,15 @@
- - + :disabled="roomState == 'init' || roomState == 427 || roomState == 404" + :class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')" + > + JOIN NOW + I'm the owner + JOIN +
@@ -157,6 +156,8 @@ 423: 'The room is closed. Please, wait for the owner to start the session.', 424: 'The room is closed. It will be open for others after you join.', 425: 'The room is ready. Please, provide a valid password.', + 426: 'The room is locked. Please, enter your name and try again.', + 427: 'Waiting for permission to join the room.', 500: 'Failed to create a session. Server error.' }, session: {} @@ -184,8 +185,7 @@ }, methods: { authSuccess() { - // The user (owner) authentication succeeded - this.roomState = 'init' + // The user authentication succeeded, we still don't know it's really the room owner this.initSession() $('#meet-setup').removeClass('hidden') @@ -194,15 +194,20 @@ configUpdate(config) { this.session.config = Object.assign({}, this.session.config, config) }, + dismissParticipant(id) { + axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss') + }, initSession(init) { this.post = { password: this.password, nickname: this.nickname, screenShare: this.canShareScreen ? 1 : 0, - init: init ? 1 : 0 + init: init ? 1 : 0, + picture: init ? this.makePicture() : '', + requestId: this.requestId() } - $('#setup-password').removeClass('is-invalid') + $('#setup-password,#setup-nickname').removeClass('is-invalid') axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) .then(response => { @@ -234,6 +239,19 @@ $('#setup-password').addClass('is-invalid').focus() } break; + + case '426': + // Locked room prerequisites error + if (init && !$('#setup-nickname').val()) { + $('#setup-nickname').addClass('is-invalid').focus() + } + break; + + case '427': + // Waiting for the owner's approval to join + // Update room state every 10 seconds + window.roomRequest = setTimeout(() => { this.initSession(true) }, 10000) + break; } }) @@ -241,6 +259,55 @@ $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, + isRoomReady() { + return ['ready', '424', '425', '426', '427'].includes(this.roomState) + }, + // An event received by the room owner when a participant is asking for a permission to join the room + joinRequest(data) { + // The toast for this user request already exists, ignore + // It's not really needed as we do this on server-side already + if ($('#i' + data.requestId).length) { + return + } + + // FIXME: Should the message close button act as the Deny button? Do we need the Deny button? + + let body = $( + `
` + + `
` + + `
` + + `

` + + `
` + + `` + + `` + ) + + this.$toast.message({ + className: 'join-request', + icon: 'user', + timeout: 0, + title: 'Join request', + // titleClassName: '', + body: body.html(), + onShow: element => { + const id = data.requestId + + $(element).find('p').text((data.nickname || '') + ' requested to join.') + + // add id attribute, so we can identify it + $(element).attr('id', 'i' + id) + // add action to the buttons + .find('button.accept,button.deny').on('click', e => { + const action = $(e.target).is('.accept') ? 'accept' : 'deny' + axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action) + .then(response => { + $('#i' + id).remove() + }) + }) + } + }) + }, + // Entering the room joinSession() { if (this.roomState == 423) { $('#meet-setup').addClass('hidden') @@ -248,7 +315,7 @@ return } - if (this.roomState == 424 || this.roomState == 425) { + if (this.roomState != 'ready') { this.initSession(true) return } @@ -267,9 +334,9 @@ this.session.menuElement = $('#meet-session-menu')[0] this.session.chatElement = $('#meet-chat')[0] this.session.onDestroy = event => { - // TODO: Handle nicely other reasons: disconnect, forceDisconnectByUser, - // forceDisconnectByServer, networkDisconnect? - if (event.reason == 'sessionClosedByServer' && !this.session.owner) { + // TODO: Display different message for each reason: forceDisconnectByUser, + // forceDisconnectByServer, sessionClosedByServer? + if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.session.owner) { $('#leave-dialog').on('hide.bs.modal', () => { // FIXME: Where exactly the user should land? Currently he'll land // on dashboard (if he's logged in) or login form (if he's not). @@ -279,21 +346,72 @@ } } + this.session.onDismiss = connId => { this.dismissParticipant(connId) } + + if (this.session.owner) { + this.session.onJoinRequest = data => { this.joinRequest(data) } + } + this.meet.joinRoom(this.session) }, logout() { - if (this.session.owner) { - axios.post('/api/v4/openvidu/rooms/' + this.room + '/close') - .then(response => { - this.meet.leaveRoom() - this.meet = null - window.location = window.config['app.url'] - }) - } else { + const logout = () => { this.meet.leaveRoom() this.meet = null window.location = window.config['app.url'] } + + if (this.session.owner) { + axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout) + } else { + logout() + } + }, + makePicture() { + const video = $("#setup-preview video")[0]; + + // Skip if video is not "playing" + if (!video.videoWidth || !this.camera) { + return '' + } + + // we're going to crop a square from the video and resize it + const maxSize = 64 + + // Calculate sizing + let sh = Math.floor(video.videoHeight / 1.5) + let sw = sh + let sx = (video.videoWidth - sw) / 2 + let sy = (video.videoHeight - sh) / 2 + + let dh = Math.min(sh, maxSize) + let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh) + + const canvas = $("")[0]; + canvas.width = dw; + canvas.height = dh; + + // draw the image on the canvas (square cropped and resized) + canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh); + + // convert it to a usable data URL (png format) + return canvas.toDataURL(); + }, + requestId() { + if (!this.reqId) { + // FIXME: Shall we use some UUID generator? Or better something that identifies the + // user/browser so we could deny the join request for a longer time. + // I'm thinking about e.g. a bad actor knocking again and again and again, + // we don't want the room owner to be bothered every few seconds. + // Maybe a solution would be to store the identifier in the browser storage + // This would not prevent hackers from sending the new identifier on every request, + // but could make sure that it is kept after page refresh for the avg user. + + // This will create max. 24-char numeric string + this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24) + } + + return this.reqId }, securityOptions() { $('#security-options-dialog').modal() diff --git a/src/resources/vue/Meet/SessionSecurityOptions.vue b/src/resources/vue/Meet/SessionSecurityOptions.vue --- a/src/resources/vue/Meet/SessionSecurityOptions.vue +++ b/src/resources/vue/Meet/SessionSecurityOptions.vue @@ -27,11 +27,11 @@ the password before they are allowed to join the meeting. -
-
-
- Locked room: - +
+ +
+ +
When the room is locked participants have to be approved by you diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue --- a/src/resources/vue/Widgets/Toast.vue +++ b/src/resources/vue/Widgets/Toast.vue @@ -82,6 +82,16 @@ } return this.addToast(data) + }, + message(data) { + if (data.type === undefined) { + data.type = 'custom' + } + if (data.timeout === undefined) { + data.timeout = this.defaultTimeout + } + + return this.addToast(data) } }, // Plugin installer method diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue --- a/src/resources/vue/Widgets/ToastMessage.vue +++ b/src/resources/vue/Widgets/ToastMessage.vue @@ -1,18 +1,18 @@ @@ -22,13 +22,19 @@ data: { type: Object, default: () => {} } }, mounted() { - $(this.$el).on('hidden.bs.toast', () => { + $(this.$el) + .on('hidden.bs.toast', () => { (this.$el).remove() this.$destroy() }) + .on('shown.bs.toast', () => { + if (this.data.onShow) { + this.data.onShow(this.$el) + } + }) .toast({ animation: true, - autohide: true, + autohide: this.data.timeout > 0, delay: this.data.timeout }) .toast('show') @@ -42,6 +48,8 @@ case 'info': case 'success': return 'text-' + this.data.type + case 'custom': + return this.data.titleClassName || '' } }, title() { @@ -54,6 +62,12 @@ case 'success': return type.charAt(0).toUpperCase() + type.slice(1) } + + return '' + }, + toastClassName() { + return 'toast hide toast-' + this.data.type + + (this.data.className ? ' ' + this.data.className : '') } } } 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,10 @@ 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::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.php b/src/tests/Browser.php --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -13,6 +13,19 @@ class Browser extends \Laravel\Dusk\Browser { /** + * Assert that the given element attribute contains specified text. + */ + public function assertAttributeRegExp($selector, $attribute, $regexp) + { + $element = $this->resolver->findOrFail($selector); + $value = (string) $element->getAttribute($attribute); + + Assert::assertRegExp($regexp, $value, "No expected text in [$selector][$attribute]. Found: $value"); + + return $this; + } + + /** * Assert number of (visible) elements */ public function assertElementsCount($selector, $expected_count, $visible = true) diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php --- a/src/tests/Browser/Components/Toast.php +++ b/src/tests/Browser/Components/Toast.php @@ -12,6 +12,7 @@ public const TYPE_SUCCESS = 'success'; public const TYPE_WARNING = 'warning'; public const TYPE_INFO = 'info'; + public const TYPE_CUSTOM = 'custom'; protected $type; protected $element; diff --git a/src/tests/Browser/Meet/RoomSecurityTest.php b/src/tests/Browser/Meet/RoomSecurityTest.php --- a/src/tests/Browser/Meet/RoomSecurityTest.php +++ b/src/tests/Browser/Meet/RoomSecurityTest.php @@ -115,7 +115,7 @@ ->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password') ->assertElementsCount('#password-input button', 1) ->click('#password-clear-btn') - ->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.') + ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") ->assertMissing('#password-input input') ->assertSeeIn('#password-input-text.text-muted', 'none') ->assertSeeIn('#password-set-btn', 'Set password') @@ -126,4 +126,123 @@ }); }); } + + /** + * Test locked room + * + * @group openvidu + */ + public function testLockedRoom(): void + { + $this->browse(function (Browser $owner, Browser $guest) { + // Make sure there's no session yet + $room = Room::where('name', 'john')->first(); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + + // Join the room as an owner (authenticate) + $owner->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') + ->click('@setup-button') + ->waitFor('@session') + // Enter Security option dialog + ->click('@menu button.link-security') + ->with(new Dialog('#security-options-dialog'), function (Browser $browser) use ($room) { + $browser->assertSeeIn('@title', 'Security options') + ->assertSeeIn('#room-lock label', 'Locked room:') + ->assertVisible('#room-lock input[type=checkbox]:not(:checked)') + ->assertVisible('#room-lock + small') + // Test setting the lock + ->click('#room-lock input') + ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") + ->click('@button-action'); + + $this->assertSame('true', $room->fresh()->getSetting('locked')); + }); + + // In another browser act as a guest + $guest->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertSeeIn('@setup-button:not([disabled]).btn-success', 'JOIN NOW') + // try without the nickname + ->click('@setup-button') + ->waitFor('@setup-nickname-input.is-invalid') + ->assertSeeIn( + '@setup-status-message.text-danger', + "The room is locked. Please, enter your name and try again." + ) + ->assertMissing('@setup-password-input') + ->assertSeeIn('@setup-button:not([disabled]).btn-success', 'JOIN NOW') + ->type('@setup-nickname-input', 'Guest

') + ->click('@setup-button') + ->assertMissing('@setup-nickname-input.is-invalid') + ->waitFor('@setup-button[disabled]') + ->assertSeeIn('@setup-status-message.text-danger', "Waiting for permission to join the room."); + + // Test denying the request (this will also test custom toasts) + $owner + ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) { + $browser->assertToastTitle('Join request') + ->assertVisible('.toast-header svg.fa-user') + ->assertSeeIn('@message', 'Guest

requested to join.') + ->assertAttributeRegExp('@message img', 'src', '|^data:image|') + ->assertSeeIn('@message button.accept.btn-success', 'Accept') + ->assertSeeIn('@message button.deny.btn-danger', 'Deny') + ->click('@message button.deny'); + }) + ->waitUntilMissing('.toast') + // wait 10 seconds to make sure the request message does not show up again + ->pause(10 * 1000) + ->assertMissing('.toast'); + + // Test accepting the request + $guest->refresh() + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->type('@setup-nickname-input', 'guest') + ->click('@setup-button') + ->waitFor('@setup-button[disabled]') + ->assertSeeIn('@setup-status-message.text-danger', "Waiting for permission to join the room."); + + $owner + ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) { + $browser->assertToastTitle('Join request') + ->assertSeeIn('@message', 'guest requested to join.') + ->click('@message button.accept'); + }); + + // Guest automatically anters the room + $guest->waitFor('@session', 12) + // make sure he has no access to the Options menu + ->waitFor('@session .meet-video:not(.publisher)') + ->assertSeeIn('@session .meet-video:not(.publisher) a.nickname', 'John') + // TODO: Assert title and icon + ->click('@session .meet-video:not(.publisher) a.nickname') + ->pause(100) + ->assertMissing('.dropdown-menu'); + + // Test dismissing the participant + $owner->click('@session .meet-video:not(.publisher) a.nickname') + ->waitFor('@session .meet-video:not(.publisher) .dropdown-menu') + ->assertSeeIn('@session .meet-video:not(.publisher) .dropdown-menu > .action-dismiss', 'Dismiss') + ->click('@session .meet-video:not(.publisher) .dropdown-menu > .action-dismiss') + ->waitUntilMissing('.dropdown-menu') + ->waitUntilMissing('@session .meet-video:not(.publisher)'); + + // Expect a "end of session" dialog on the participant side + $guest->with(new Dialog('#leave-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Room closed') + ->assertSeeIn('@body', "The session has been closed by the room owner.") + ->assertMissing('@button-cancel') + ->assertSeeIn('@button-action', 'Close'); + }); + }); + } } 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 @@ -47,6 +47,8 @@ $browser->assertMissing('#footer-menu .navbar-nav'); } + // FIXME: Maybe it would be better to just display the usual 404 Not Found error page? + $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') @@ -54,7 +56,7 @@ ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room does not exist.") - ->assertMissing('@setup-button'); + ->assertVisible('@setup-button[disabled]'); }); } 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 @@ -88,6 +88,8 @@ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing"); $response->assertStatus(404); + // TODO: Test accessing an existing room of deleted owner + // Non-owner, no session yet $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(423); @@ -103,7 +105,7 @@ $session_id = $room->fresh()->session_id; - $this->assertSame('PUBLISHER', $json['role']); + $this->assertSame(Room::ROLE_MODERATOR, $json['role']); $this->assertSame($session_id, $json['session']); $this->assertTrue(is_string($session_id) && !empty($session_id)); $this->assertTrue(strpos($json['token'], 'wss://') === 0); @@ -117,7 +119,7 @@ $json = $response->json(); - $this->assertSame('PUBLISHER', $json['role']); + $this->assertSame(Room::ROLE_PUBLISHER, $json['role']); $this->assertSame($session_id, $json['session']); $this->assertTrue(strpos($json['token'], 'wss://') === 0); $this->assertTrue($json['token'] != $john_token); @@ -153,8 +155,129 @@ // Make sure the room owner can access the password protected room w/o password $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(200); + } - // TODO: Test accessing an existing room of deleted owner + /** + * Test locked room and join requests + * + * @group openvidu + */ + public function testJoinRequests(): 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(); + $room->setSettings(['password' => null, 'locked' => 'true']); + + $this->assignBetaEntitlement($john, 'meet'); + + // Create the session (also makes sure the owner can access a locked room) + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + // Non-owner, locked room, invalid/missing input + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); + $response->assertStatus(426); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to join the session. Room locked.', $json['message']); + $this->assertTrue($json['config']['locked']); + + // Non-owner, locked room, invalid requestId + $post = ['nickname' => 'name', 'requestId' => '-----']; + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(426); + + // Non-owner, locked room, invalid requestId + $post = ['nickname' => 'name', 'picture' => '-----']; + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(426); + + // Non-owner, locked room, valid input + $reqId = '12345678'; + $post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234']; + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(427); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to join the session. Room locked.', $json['message']); + $this->assertTrue($json['config']['locked']); + + // TODO: How do we assert that a signal has been sent to the owner? + + // Test denying a request + + // Unknown room + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/deny"); + $response->assertStatus(404); + + // Unknown request Id + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/deny"); + $response->assertStatus(500); + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to deny the join request.', $json['message']); + + // Non-owner access forbidden + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny"); + $response->assertStatus(403); + + // Valid request + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny"); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertSame('success', $json['status']); + + // Non-owner, locked room, join request denied + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(427); + + // Test accepting a request + + // Unknown room + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/accept"); + $response->assertStatus(404); + + // Unknown request Id + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/accept"); + $response->assertStatus(500); + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to accept the join request.', $json['message']); + + // Non-owner access forbidden + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept"); + $response->assertStatus(403); + + // Valid request + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept"); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertSame('success', $json['status']); + + // Non-owner, locked room, join request accepted + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertSame(Room::ROLE_PUBLISHER, $json['role']); + $this->assertTrue(strpos($json['token'], 'wss://') === 0); + + // TODO: Test a scenario where both password and lock are enabled } /** @@ -177,7 +300,7 @@ $json = $response->json(); - $this->assertSame('PUBLISHER', $json['role']); + $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); @@ -238,6 +361,69 @@ } /** + * Test dismissing a participant (closing a connection) + * + * @group openvidu + */ + public function testDismissConnection(): 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->assignBetaEntitlement($john, 'meet'); + + // First we create the session + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + // 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']; + $room->refresh(); + $conn_data = $room->getOVConnection($conn_id); + + $this->assertSame($conn_id, $conn_data['connectionId']); + + // Non-existing room name + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}/dismiss"); + $response->assertStatus(404); + + // TODO: Test accessing an existing room of deleted owner + + // Non-existing connection + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/123/dismiss"); + $response->assertStatus(500); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to dismiss the connection.', $json['message']); + + // Non-owner access + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss"); + $response->assertStatus(403); + + // Expected success + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertNull($room->getOVConnection($conn_id)); + } + + /** * Test configuring the room (session) * * @group openvidu