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 @@ -10,6 +10,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. * @@ -136,19 +198,52 @@ 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 = $room->requestGet($requestId); + + // Request already has been processed (not accepted yet, but it could be denied) + if ($request && $request['status'] != Room::REQUEST_ACCEPTED) { + return $this->errorResponse(427, \trans('meet.session-room-locked'), ['config' => $config]); + } + + if (!$request) { + if (empty($nickname) || empty($requestId)) { + return $this->errorResponse(426, \trans('meet.session-room-locked'), ['config' => $config]); + } + + // TODO: Validate requestId and nickname + // TODO: Validate/resize/make safe the user picture + + if (empty($picture)) { + $svg = file_get_contents(resource_path('images/user.svg')); + $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; + + if (!$room->requestSave($requestId, $request)) { + // FIXME: should we use error code 500? + return $this->errorResponse(426, \trans('meet.session-room-locked'), ['config' => $config]); + } + + // Send the request (signal) to the owner + $result = $room->signal('joinRequest', $request, 'MODERATOR'); + + return $this->errorResponse(427, \trans('meet.session-room-locked'), ['config' => $config]); } } // Create session token for the current user/connection - $response = $room->getSessionToken('PUBLISHER'); + $response = $room->getSessionToken($isOwner ? 'MODERATOR' : 'PUBLISHER'); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); 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,9 @@ { use SettingsTrait; + public const REQUEST_ACCEPTED = 'accepted'; + public const REQUEST_DENIED = 'denied'; + protected $fillable = [ 'user_id', 'name' @@ -73,6 +77,7 @@ if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); + return null; } $session = json_decode($response->getBody(), true); @@ -162,6 +167,75 @@ return $this->belongsTo('\App\User', 'user_id', 'id'); } + /** + * 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; + } + + /** + * Accept the join request + * + * @param string $id Request identifier + * + * @return array|null Request data (e.g. nickname, status, picture?) + */ + public function requestGet(string $id) + { + 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. * @@ -171,4 +245,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 @@ -70,7 +70,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(); @@ -414,7 +414,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 +431,12 @@ data.id = connId pushChatMessage(data) break + + case 'signal:joinRequest': + if (sessionData.onJoinRequest) { + sessionData.onJoinRequest(JSON.parse(signal.data)) + } + break; } } 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 @@ -253,3 +253,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" + :class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')" + > + I'm the owner + JOIN NOW + 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') @@ -199,10 +199,12 @@ 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 +236,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 +256,53 @@ $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, + isRoomReady() { + return ['ready', '424', '425'].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 = $( + `
` + + `
` + + `
` + + `

${data.nickname || ''} requested to join.

` + + `
` + + `` + + `` + ) + + this.$toast.message({ + className: 'join-request', + icon: 'user', + timeout: 0, + title: 'Join request', + // titleClassName: '', + body: body.html(), + onShow: element => { + const id = data.requestId + + // 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 +310,7 @@ return } - if (this.roomState == 424 || this.roomState == 425) { + if (this.roomState != 'ready') { this.initSession(true) return } @@ -279,21 +341,70 @@ } } + 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,8 +27,8 @@ the password before they are allowed to join the meeting. -
-
+
+
Locked room: 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 @@ -81,6 +81,16 @@ data.title = title } + 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) } }, 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,8 @@ 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'); + Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); + Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } );