diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index ed3c7f67..0678d895 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,174 +1,261 @@ <?php namespace App\Http\Controllers\API\V4; use App\Http\Controllers\Controller; use App\OpenVidu\Room; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; class OpenViduController extends Controller { /** * Close the room session. * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function closeRoom($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')); } $user = Auth::guard()->user(); // Only the room owner can do it if (!$user || $user->id != $room->user_id) { return $this->errorResponse(403); } if (!$room->deleteSession()) { return $this->errorResponse(500, \trans('meet.session-close-error')); } return response()->json([ 'status' => 'success', 'message' => __('meet.session-close-success'), ]); } /** * Listing of rooms that belong to the current user. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::guard()->user(); $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); if (count($rooms) == 0) { // Create a room for the user (with a random and unique name) while (true) { $name = strtolower(\App\Utils::randStr(3, 3, '-')); if (!Room::where('name', $name)->count()) { break; } } $room = Room::create([ 'name' => $name, 'user_id' => $user->id ]); $rooms = collect([$room]); } $result = [ 'list' => $rooms, 'count' => count($rooms), ]; return response()->json($result); } /** * Join the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function joinRoom($id) { $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted if (!$room || !$room->owner) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Check if there's still a valid beta entitlement for the room owner $sku = \App\Sku::where('title', 'meet')->first(); if ($sku && !$room->owner->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); + $isOwner = $user && $user->id == $room->user_id; // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner - if (!$user || $user->id != $room->user_id) { + if (!$isOwner) { return $this->errorResponse(423, \trans('meet.session-not-found')); } // The room owner can create the session on request if (empty(request()->input('init'))) { return $this->errorResponse(424, \trans('meet.session-not-found')); } $session = $room->createSession(); if (empty($session)) { return $this->errorResponse(500, \trans('meet.session-create-error')); } } + $password = (string) $room->getSetting('password'); + + $config = [ + 'locked' => $room->getSetting('locked') === 'true', + 'password' => $isOwner ? $password : '', + 'requires_password' => !$isOwner && strlen($password), + ]; + + // Validate room password + 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); + } + } + // Create session token for the current user/connection $response = $room->getSessionToken('PUBLISHER'); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); } // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken('PUBLISHER'); $response['shareToken'] = $add_token['token']; } // Tell the UI who's the room owner - $response['owner'] = $user && $user->id == $room->user_id; + $response['owner'] = $isOwner; + + // Append the room configuration + + $response['config'] = $config; return response()->json($response); } + /** + * Set the domain configuration. + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setRoomConfig($id) + { + $room = Room::where('name', $id)->first(); + + // Room does not exist, or the owner is deleted + if (!$room || !$room->owner) { + return $this->errorResponse(404); + } + + $user = Auth::guard()->user(); + + // Only room owner can configure the room + if ($user->id != $room->user_id) { + return $this->errorResponse(403); + } + + $input = request()->input(); + $errors = []; + + foreach ($input as $key => $value) { + switch ($key) { + case 'password': + if ($value === null || $value === '') { + $input[$key] = null; + } else { + // TODO: Do we have to validate the password in any way? + } + break; + + case 'locked': + $input[$key] = $value ? 'true' : null; + break; + + default: + $errors[$key] = \trans('meet.room-unsupported-option-error'); + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!empty($input)) { + $room->setSettings($input); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('meet.room-setconfig-success'), + ]); + } + /** * Webhook as triggered from OpenVidu server * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function webhook(Request $request) { \Log::debug($request->getContent()); switch ((string) $request->input('event')) { case 'sessionDestroyed': // When all participants left the room OpenVidu dispatches sessionDestroyed // event. We'll remove the session reference from the database. $sessionId = $request->input('sessionId'); $room = Room::where('session_id', $sessionId)->first(); if ($room) { $room->session_id = null; $room->save(); } break; } return response('Success', 200); } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php index da291736..3579fab5 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,166 +1,174 @@ <?php namespace App\OpenVidu; use App\Traits\SettingsTrait; use Illuminate\Database\Eloquent\Model; +/** + * The eloquent definition of a Room. + * + * @property int $id Room identifier + * @property string $name Room name + * @property int $user_id Room owner + * @property ?string $session_id OpenVidu session identifier + */ class Room extends Model { use SettingsTrait; protected $fillable = [ 'user_id', 'name' ]; protected $table = 'openvidu_rooms'; /** @var \GuzzleHttp\Client|null HTTP client instance */ private static $client = null; /** * Creates HTTP client for connections to OpenVidu server * * @return \GuzzleHttp\Client HTTP client instance */ private function client() { if (!self::$client) { self::$client = new \GuzzleHttp\Client( [ 'http_errors' => false, // No exceptions from Guzzle 'base_uri' => \config('openvidu.api_url'), 'verify' => \config('openvidu.api_verify_tls'), 'auth' => [ \config('openvidu.api_username'), \config('openvidu.api_password') ] ] ); } return self::$client; } /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise */ public function createSession(): ?array { $response = $this->client()->request( 'POST', "sessions", [ 'json' => [ 'mediaMode' => 'ROUTED', 'recordingMode' => 'MANUAL' ] ] ); if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); } $session = json_decode($response->getBody(), true); $this->session_id = $session['id']; $this->save(); return $session; } /** * Delete a OpenVidu session * * @return bool */ public function deleteSession(): bool { if (!$this->session_id) { return true; } $response = $this->client()->request( 'DELETE', "sessions/" . $this->session_id, ); if ($response->getStatusCode() == 204) { $this->session_id = null; $this->save(); return true; } return false; } /** * Create a OpenVidu session (connection) token * * @return array|null Token data on success, NULL otherwise */ public function getSessionToken($role = 'PUBLISHER'): ?array { $response = $this->client()->request( 'POST', 'tokens', [ 'json' => [ 'session' => $this->session_id, 'role' => $role ] ] ); if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); return $json; } return null; } /** * Check if the room has an active session * * @return bool True when the session exists, False otherwise */ public function hasSession(): bool { if (!$this->session_id) { return false; } $response = $this->client()->request('GET', "sessions/{$this->session_id}"); return $response->getStatusCode() == 200; } /** * The room owner. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('\App\User', 'user_id', 'id'); } /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } } diff --git a/src/resources/js/meet.js b/src/resources/js/meet.js index 4665f826..c52aed93 100644 --- a/src/resources/js/meet.js +++ b/src/resources/js/meet.js @@ -1,38 +1,40 @@ /** * Application code for the Meet UI */ import routes from './routes-meet.js' window.routes = routes window.isAdmin = false require('./app') // Register additional icons import { library } from '@fortawesome/fontawesome-svg-core' import { faAlignLeft, faCompress, faDesktop, faExpand, faMicrophone, faPowerOff, faUser, + faShieldAlt, faVideo, faVolumeMute } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( faAlignLeft, faCompress, faDesktop, faExpand, faMicrophone, faPowerOff, faUser, + faShieldAlt, faVideo, faVolumeMute ) diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php index 7958bd85..6f0341c7 100644 --- a/src/resources/lang/en/meet.php +++ b/src/resources/lang/en/meet.php @@ -1,22 +1,26 @@ <?php return [ /* |-------------------------------------------------------------------------- | Pagination Language Lines |-------------------------------------------------------------------------- | | The following language lines are used by the paginator library to build | the simple pagination links. You are free to change them to anything | you want to customize your views to better match your application. | */ 'room-not-found' => 'The room does not exist.', + 'room-setconfig-success' => 'Room configuration updated successfully.', + 'room-unsupported-option-error' => 'Invalid room configuration option.', 'session-not-found' => 'The session does not exist.', 'session-create-error' => 'Failed to create the session.', '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-password-error' => 'Failed to join the session. Invalid password.', + ]; diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index 5690b8ed..f8bd1d75 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,47 +1,77 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } .range-input { display: flex; label { margin-right: 0.5em; } } +.input-group-activable { + &.active { + :not(.input-group-append):not(.activable) { + display: none; + } + } + + &:not(.active) { + .activable { + display: none; + } + } + + // Label is always visible + .label { + color: $body-color; + display: initial !important; + } + + .input-group-text { + border-color: transparent; + background: transparent; + padding-left: 0; + + &:not(.label) { + flex: 1; + } + } +} + .form-control-plaintext .btn-sm { margin-top: -0.25rem; } form.read-only { .row { margin-bottom: 0; } } diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss index ed13c224..00bdbf11 100644 --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -1,251 +1,255 @@ .meet-video { position: relative; background: $menu-bg-color; // Use flexbox for centering .watermark display: flex; 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. object-fit: cover; width: 100%; height: 100%; background: #000; & + .watermark { display: none; } } &.fullscreen { video { // We don't want the video to be cut in fullscreen // This will preserve the aspect ratio of the video stream object-fit: contain; } } .controls { position: absolute; bottom: 0; right: 0; margin: 0.5em; padding: 0 0.05em; line-height: 2em; border-radius: 1em; background: rgba(#000, 0.7); button { line-height: 2; border-radius: 50%; padding: 0; width: 2em; } } .status { position: absolute; bottom: 0; left: 0; margin: 0.5em; line-height: 2em; span { display: inline-block; color: #fff; border-radius: 50%; width: 2em; text-align: center; margin-right: 0.25em; } } .nickname { position: absolute; top: 0; left: 0; margin: 0.5em; padding: 0 1em; line-height: 2em; border-radius: 1em; max-width: calc(100% - 1em); background: rgba(#fff, 0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; button { display: none; } span { outline: none; } } &.publisher .nickname { cursor: pointer; background: rgba($main-color, 0.9); &:focus-within { box-shadow: $btn-focus-box-shadow; } span: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; } } } } #meet-component { flex-grow: 1; display: flex; flex-direction: column; & + .filler { display: none; } } #app.meet { height: 100%; #meet-component { overflow: hidden; } } #meet-setup { max-width: 720px; + + .input-group svg { + width: 1em; + } } #meet-auth { margin-top: 2rem; margin-bottom: 2rem; flex: 1; } #meet-session-toolbar { display: flex; justify-content: center; } #meet-session-menu { button { font-size: 1.3em; padding: 0 0.25em; margin: 0.5em; position: relative; .badge { font-size: 0.5em; position: absolute; right: -0.5em; &:empty { display: none; } } } } #meet-session-layout { flex: 1; overflow: hidden; } #meet-session { display: flex; justify-content: center; flex-wrap: wrap; flex: 1; //overflow: hidden; } #meet-chat { width: 0; display: none; flex-direction: column; &.open { width: 30%; display: flex !important; .mobile & { width: 100%; z-index: 1; background: $body-bg; } } .chat { flex: 1; overflow-y: auto; } .message { margin: 0 0.5em 0.5em 0.5em; padding: 0.25em 0.5em; border-radius: 1em; background: $menu-bg-color; overflow-wrap: break-word; &.self { background: lighten($main-color, 30%); } } .nickname { font-size: 80%; color: $secondary; text-align: right; } // TODO: mobile mode } #setup-preview { display: flex; video { width: 100%; transform: rotateY(180deg); background: #000; } .volume { height: 50%; position: absolute; bottom: 1em; right: 2em; width: 0.5em; background: rgba(0, 0, 0, 0.5); .bar { width: 100%; position: absolute; bottom: 0; } } } diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue index 549755eb..f5951b11 100644 --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -1,352 +1,397 @@ <template> <div id="meet-component"> <div id="meet-session-toolbar" class="hidden"> <div id="meet-session-menu"> <button class="btn btn-link link-audio" @click="switchSound" title="Mute audio"> <svg-icon icon="microphone"></svg-icon> </button> <button class="btn btn-link link-video" @click="switchVideo" title="Mute video"> <svg-icon icon="video"></svg-icon> </button> <button class="btn btn-link link-screen text-danger" @click="switchScreen" :disabled="!canShareScreen" title="Share screen"> <svg-icon icon="desktop"></svg-icon> </button> <button class="btn btn-link link-chat text-danger" @click="switchChat" title="Chat"> <svg-icon icon="align-left"></svg-icon> </button> <button class="btn btn-link link-fullscreen closed hidden" @click="switchFullscreen" title="Full screen"> <svg-icon icon="expand"></svg-icon> </button> <button class="btn btn-link link-fullscreen open hidden" @click="switchFullscreen" title="Full screen"> <svg-icon icon="compress"></svg-icon> </button> + <button class="btn btn-link link-security" v-if="session && session.owner" @click="securityOptions" title="Security options"> + <svg-icon icon="shield-alt"></svg-icon> + </button> <button class="btn btn-link link-logout" @click="logout" title="Leave session"> <svg-icon icon="power-off"></svg-icon> </button> </div> </div> <div id="meet-setup" class="card container mt-2 mt-md-5 mb-5"> <div class="card-body"> <div class="card-title">Set up your session</div> <div class="card-text"> <form class="setup-form row"> <div id="setup-preview" class="col-sm-6 mb-3 mb-sm-0"> <video class="rounded"></video> <div class="volume"><div class="bar"></div></div> </div> - <div class="col-sm-6"> - <div class="form-group"> - <label for="setup-microphone">Microphone</label> + <div class="col-sm-6 align-self-center"> + <div class="input-group"> + <label for="setup-microphone" class="input-group-prepend mb-0"> + <span class="input-group-text" title="Microphone"><svg-icon icon="microphone"></svg-icon></span> + </label> <select class="custom-select" id="setup-microphone" v-model="microphone" @change="setupMicrophoneChange"> <option value="">None</option> <option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option> </select> </div> - <div class="form-group"> - <label for="setup-camera">Camera</label> + <div class="input-group mt-2"> + <label for="setup-camera" class="input-group-prepend mb-0"> + <span class="input-group-text" title="Camera"><svg-icon icon="video"></svg-icon></span> + </label> <select class="custom-select" id="setup-camera" v-model="camera" @change="setupCameraChange"> <option value="">None</option> <option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option> </select> </div> - <div class="form-group mb-0"> - <label for="setup-nickname">Nickname</label> - <input class="form-control" type="text" id="setup-nickname" v-model="nickname"> + <div class="input-group mt-2"> + <label for="setup-nickname" class="input-group-prepend mb-0"> + <span class="input-group-text" title="Nickname"><svg-icon icon="user"></svg-icon></span> + </label> + <input class="form-control" type="text" id="setup-nickname" v-model="nickname" placeholder="Your name"> + </div> + <div class="input-group mt-2" v-if="session.config && session.config.requires_password"> + <label for="setup-password" class="input-group-prepend mb-0"> + <span class="input-group-text" title="Password"><svg-icon icon="key"></svg-icon></span> + </label> + <input type="password" class="form-control" id="setup-password" v-model="password" placeholder="Password"> + </div> + <div class="mt-3"> + <button v-if="roomState == 'ready' || roomState == 424 || roomState == 425" + type="button" + @click="joinSession" + :class="'btn w-100 btn-' + (roomState == 'ready' ? 'success' : 'primary')" + >JOIN</button> + <button v-if="roomState == 423" + type="button" + @click="joinSession" + class="btn btn-primary w-100" + >I'm the owner</button> </div> </div> - <div class="text-center mt-4 col-sm-12"> - <status-message :status="roomState" :status-labels="roomStateLabels" class="mb-3"></status-message> - <button v-if="roomState == 'ready' || roomState == 424" - type="button" - @click="joinSession" - class="btn btn-primary pl-5 pr-5" - >JOIN</button> - <button v-if="roomState == 423" - type="button" - @click="joinSession" - class="btn btn-primary pl-5 pr-5" - >I'm the owner</button> + <div class="mt-4 col-sm-12"> + <status-message :status="roomState" :status-labels="roomStateLabels"></status-message> </div> </form> </div> </div> </div> <div id="meet-session-layout" class="d-flex hidden"> <div id="meet-session"></div> <div id="meet-chat"> <div class="chat"></div> <div class="chat-input m-2"> <textarea class="form-control" rows="1"></textarea> </div> </div> </div> <logon-form id="meet-auth" class="hidden" :dashboard="false" @success="authSuccess"></logon-form> <div id="leave-dialog" class="modal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">Room closed</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body"> <p>The session has been closed by the room owner.</p> </div> <div class="modal-footer"> <button type="button" class="btn btn-danger modal-action" data-dismiss="modal">Close</button> </div> </div> </div> </div> + + <session-security-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></session-security-options> </div> </template> <script> import Meet from '../../js/meet/app.js' import StatusMessage from '../Widgets/StatusMessage' import LogonForm from '../Login' + import SessionSecurityOptions from './SessionSecurityOptions' export default { components: { LogonForm, + SessionSecurityOptions, StatusMessage }, data() { return { setup: { cameras: [], microphones: [], }, canShareScreen: false, camera: '', meet: null, microphone: '', nickname: '', + password: '', room: null, roomState: 'init', roomStateLabels: { init: 'Checking the room...', 404: 'The room does not exist.', 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.', 500: 'Failed to create a session. Server error.' }, session: {} } }, mounted() { this.room = this.$route.params.room // Initialize OpenVidu and do some basic checks this.meet = new Meet($('#meet-session')[0]); this.canShareScreen = this.meet.isScreenSharingSupported() // Check the room and init the session this.initSession() // Setup the room UI this.setupSession() }, beforeDestroy() { clearTimeout(window.roomRequest) if (this.meet) { this.meet.leaveRoom() } }, methods: { authSuccess() { // The user (owner) authentication succeeded this.roomState = 'init' this.initSession() $('#meet-setup').removeClass('hidden') $('#meet-auth').addClass('hidden') }, + configUpdate(config) { + this.session.config = Object.assign({}, this.session.config, config) + }, initSession(init) { - let params = [] - - if (this.canShareScreen) { - params.push('screenShare=1') + this.post = { + password: this.password, + nickname: this.nickname, + screenShare: this.canShareScreen ? 1 : 0, + init: init ? 1 : 0 } - if (init) { - params.push('init=1') - } + $('#setup-password').removeClass('is-invalid') - const url = '/api/v4/openvidu/rooms/' + this.room + '?' + params.join('&') - - axios.get(url, { ignoreErrors: true }) + axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) .then(response => { // Response data contains: session, token and shareToken this.roomState = 'ready' this.session = response.data if (init) { this.joinSession() } }) .catch(error => { this.roomState = String(error.response.status) - // Waiting for the owner to open the room... - if (error.response.status == 423) { - // Update room state every 10 seconds - window.roomRequest = setTimeout(() => { this.initSession() }, 10000) + if (error.response.data && error.response.data.config) { + this.session.config = error.response.data.config + } + + switch (this.roomState) { + case '423': + // Waiting for the owner to open the room... + // Update room state every 10 seconds + window.roomRequest = setTimeout(() => { this.initSession() }, 10000) + break; + + case '425': + // Missing/invalid password + if (init) { + $('#setup-password').addClass('is-invalid').focus() + } + break; } }) if (document.fullscreenEnabled) { $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, joinSession() { if (this.roomState == 423) { $('#meet-setup').addClass('hidden') $('#meet-auth').removeClass('hidden') return } - if (this.roomState == 424) { + if (this.roomState == 424 || this.roomState == 425) { this.initSession(true) return } clearTimeout(window.roomRequest) $('#app').addClass('meet') $('#meet-setup').addClass('hidden') $('#meet-session-toolbar,#meet-session-layout').removeClass('hidden') + if (!this.canShareScreen) { + this.setMenuItem('screen', false, true) + } + this.session.nickname = this.nickname 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) { $('#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). window.location = window.config['app.url'] }).modal() } } 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 { this.meet.leaveRoom() this.meet = null window.location = window.config['app.url'] } }, + securityOptions() { + $('#security-options-dialog').modal() + }, setMenuItem(type, state, disabled) { let button = $('#meet-session-menu').find('.link-' + type) button[state ? 'removeClass' : 'addClass']('text-danger') if (disabled !== undefined) { button.prop('disabled', disabled) } }, setupSession() { this.meet.setup({ videoElement: $('#setup-preview video')[0], volumeElement: $('#setup-preview .volume')[0], onSuccess: setup => { this.setup = setup this.microphone = setup.audioSource this.camera = setup.videoSource this.setMenuItem('audio', setup.audioActive) this.setMenuItem('video', setup.videoActive) }, onError: error => { this.setMenuItem('audio', false, true) this.setMenuItem('video', false, true) } }) }, setupCameraChange() { this.meet.setupSetVideoDevice(this.camera).then(enabled => { this.setMenuItem('video', enabled) }) }, setupMicrophoneChange() { this.meet.setupSetAudioDevice(this.microphone).then(enabled => { this.setMenuItem('audio', enabled) }) }, switchChat() { let chat = $('#meet-chat') let enabled = chat.is('.open') this.setMenuItem('chat', !enabled) chat.toggleClass('open') if (!enabled) { chat.find('textarea').focus() } // Trigger resize, so participant matrix can update its layout window.dispatchEvent(new Event('resize')); }, switchFullscreen() { const element = this.$el $(element).off('fullscreenchange').on('fullscreenchange', (e) => { let enabled = document.fullscreenElement == element let buttons = $('#meet-session-menu').find('.link-fullscreen') buttons.first()[enabled ? 'addClass' : 'removeClass']('hidden') buttons.last()[!enabled ? 'addClass' : 'removeClass']('hidden') }) if (document.fullscreenElement) { document.exitFullscreen() } else { element.requestFullscreen() } }, switchSound() { const enabled = this.meet.switchAudio() this.setMenuItem('audio', enabled) }, switchVideo() { const enabled = this.meet.switchVideo() 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) { - axios.get('/api/v4/openvidu/rooms/' + this.room, { ignoreErrors: true }) + // TODO: This might need to be a different route. E.g. the room password might have + // changed since user joined the session + 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.token this.meet.updateSession(this.session) }) } }) } } } </script> diff --git a/src/resources/vue/Meet/SessionSecurityOptions.vue b/src/resources/vue/Meet/SessionSecurityOptions.vue new file mode 100644 index 00000000..8d6a2b78 --- /dev/null +++ b/src/resources/vue/Meet/SessionSecurityOptions.vue @@ -0,0 +1,110 @@ +<template> + <div v-if="config"> + <div id="security-options-dialog" class="modal" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">Security options</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <form id="security-options-password"> + <div id="password-input" class="input-group input-group-activable"> + <span class="input-group-text label">Password:</span> + <span v-if="config.password" id="password-input-text" class="input-group-text">{{ config.password }}</span> + <span v-else id="password-input-text" class="input-group-text text-muted">none</span> + <input type="text" :value="config.password" name="password" class="form-control rounded-left activable"> + <div class="input-group-append"> + <button type="button" @click="passwordSave" id="password-save-btn" class="btn btn-outline-primary activable rounded-right">Save</button> + <button type="button" v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn btn-outline-danger rounded">Clear password</button> + <button type="button" v-else @click="passwordSet" id="password-set-btn" class="btn btn-outline-primary rounded">Set password</button> + </div> + </div> + <small class="form-text text-muted"> + You can add a password to your meeting. Participants will have to provide + the password before they are allowed to join the meeting. + </small> + </form> + <hr v-if="false"> + <form v-if="false" id="security-options-lock"> + <div id="room-lock" class=""> + <span class="">Locked room:</span> + <input type="checkbox" name="lock" value="1" :checked="config.locked" @click="lockSave"> + </div> + <small class="form-text text-muted"> + When the room is locked participants have to be approved by you + before they could join the meeting. + </small> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">Close</button> + </div> + </div> + </div> + </div> + </div> +</template> + +<script> + export default { + props: { + config: { type: Object, default: () => null }, + room: { type: String, default: () => null } + }, + data() { + return { + } + }, + mounted() { + $('#security-options-dialog').on('show.bs.modal', e => { + $(e.target).find('.input-group-activable.active').removeClass('active') + }) + }, + methods: { + configSave(name, value, callback) { + const post = {} + post[name] = value + + axios.post('/api/v4/openvidu/rooms/' + this.room + '/config', post) + .then(response => { + this.config[name] = value + if (callback) { + callback(response.data) + } + this.$emit('config-update', this.config) + this.$toast.success(response.data.message) + }) + }, + lockSave(e) { + this.configSave('locked', $(e.target).prop('checked') ? 1 : 0) + }, + passwordClear() { + this.configSave('password', '') + }, + passwordSave() { + this.configSave('password', $('#password-input input').val(), () => { + $('#password-input').removeClass('active') + }) + }, + passwordSet() { + $('#password-input').addClass('active').find('input') + .off('keydown.pass') + .on('keydown.pass', e => { + if (e.which == 13) { + // On ENTER save the password + this.passwordSave() + e.preventDefault() + } else if (e.which == 27) { + // On ESC escape from the input, but not the dialog + $('#password-input').removeClass('active') + e.stopPropagation() + } + }) + .focus() + } + } + } +</script> diff --git a/src/routes/api.php b/src/routes/api.php index ae56d9b7..86421a64 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,149 +1,150 @@ <?php use Illuminate\Http\Request; /* |-------------------------------------------------------------------------- | API Routes |-------------------------------------------------------------------------- | | Here is where you can register API routes for your application. These | routes are loaded by the RouteServiceProvider within a group which | is assigned the "api" middleware group. Enjoy building your API! | */ $prefix = \trim(\parse_url(\config('app.url'), PHP_URL_PATH), '/') . '/'; Route::group( [ 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); 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'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { - Route::get('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); + Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); diff --git a/src/routes/websocket.php b/src/routes/websocket.php index 64240f04..3fd6d85b 100644 --- a/src/routes/websocket.php +++ b/src/routes/websocket.php @@ -1,41 +1,37 @@ <?php use Illuminate\Http\Request; use SwooleTW\Http\Websocket\Facades\Websocket; /* |-------------------------------------------------------------------------- | Websocket Routes |-------------------------------------------------------------------------- | | Here is where you can register websocket events for your application. | */ Websocket::on( 'connect', function ($websocket, Request $request) { - \Log::debug("someone connected"); - $websocket->emit( - 'message', - 'welcome' - ); + return; } ); Websocket::on( 'open', function ($websocket, Request $request) { - \Log::debug("socket opened"); + return; } ); Websocket::on( 'disconnect', function ($websocket) { - \Log::debug("someone disconnected"); + return; } ); -Websocket::on('message', 'App\Http\Controllers\WebsocketController@message'); -Websocket::on('ping', 'App\Http\Controllers\WebsocketController@ping'); +//Websocket::on('message', 'App\Http\Controllers\WebsocketController@message'); +//Websocket::on('ping', 'App\Http\Controllers\WebsocketController@ping'); diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php index 7be87eeb..67f1f0c0 100644 --- a/src/tests/Browser/Meet/RoomControlsTest.php +++ b/src/tests/Browser/Meet/RoomControlsTest.php @@ -1,341 +1,342 @@ <?php namespace Tests\Browser\Meet; use App\OpenVidu\Room; use Tests\Browser; use Tests\Browser\Pages\Meet\Room as RoomPage; use Tests\TestCaseDusk; class RoomControlsTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->clearBetaEntitlements(); } public function tearDown(): void { $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test fullscreen buttons * * @group openvidu */ public function testFullscreen(): void { // TODO: This test does not work in headless mode $this->markTestIncomplete(); // Make sure there's no session yet $room = Room::where('name', 'john')->first(); if ($room->session_id) { $room->session_id = null; $room->save(); } $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $browser) { // Join the room as an owner (authenticate) $browser->visit(new RoomPage('john')) ->click('@setup-button') ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@setup-form') ->assertVisible('@login-form') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->assertMissing('@login-form') ->waitUntilMissing('@setup-status-message.loading') ->click('@setup-button') ->waitFor('@session') // Test fullscreen for the whole room ->click('@menu button.link-fullscreen.closed') ->assertVisible('@toolbar') ->assertVisible('@session') ->assertMissing('nav') ->assertMissing('@menu button.link-fullscreen.closed') ->click('@menu button.link-fullscreen.open') ->assertVisible('nav') // Test fullscreen for the participant video ->click('@session button.link-fullscreen.closed') ->assertVisible('@session') ->assertMissing('@toolbar') ->assertMissing('nav') ->assertMissing('@session button.link-fullscreen.closed') ->click('@session button.link-fullscreen.open') ->assertVisible('nav') ->assertVisible('@toolbar'); }); } /** * Test nickname and muting audio/video * * @group openvidu */ public function testNicknameAndMuting(): void { // Make sure there's no session yet $room = Room::where('name', 'john')->first(); if ($room->session_id) { $room->session_id = null; $room->save(); } $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $owner, Browser $guest) { // 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'); // In another browser act as a guest $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-status-message') ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->click('@setup-button') ->waitFor('@session'); // Assert current UI state $owner->assertToolbar([ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, - 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, + 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, + 'security' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, ]) ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') ->assertAudioMuted('video', true) ->assertSeeIn('.nickname', 'john') ->assertMissing('.nickname button') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertMissing('video') ->assertMissing('.nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Assert current UI state $guest->assertToolbar([ 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, - 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, + 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, ]) ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') //->assertAudioMuted('video', true) ->assertVisible('.nickname button') ->assertMissing('.nickname span') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertVisible('video') ->assertSeeIn('.nickname', 'john') ->assertMissing('.nickname button') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Test nickname change propagation // Use script() because type() does not work with this contenteditable widget $guest->setNickname('div.meet-video.publisher', 'guest'); $owner->waitFor('div.meet-video:not(.publisher) .nickname') ->assertSeeIn('div.meet-video:not(.publisher) .nickname', 'guest'); // Test muting audio $owner->click('@menu button.link-audio') ->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED) ->assertVisible('div.meet-video.publisher .status .status-audio'); // FIXME: It looks that we can't just check the <video> element state // We might consider using OpenVidu API to make sure $guest->waitFor('div.meet-video:not(.publisher) .status .status-audio'); // Test unmuting audio $owner->click('@menu button.link-audio') ->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED) ->assertMissing('div.meet-video.publisher .status .status-audio'); $guest->waitUntilMissing('div.meet-video:not(.publisher) .status .status-audio'); // Test muting video $owner->click('@menu button.link-video') ->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED) ->assertVisible('div.meet-video.publisher .status .status-video'); // FIXME: It looks that we can't just check the <video> element state // We might consider using OpenVidu API to make sure $guest->waitFor('div.meet-video:not(.publisher) .status .status-video'); // Test unmuting video $owner->click('@menu button.link-video') ->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED) ->assertMissing('div.meet-video.publisher .status .status-video'); $guest->waitUntilMissing('div.meet-video:not(.publisher) .status .status-video'); // Test muting other user $guest->with('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->click('.controls button.link-audio') ->assertAudioMuted('video', true) ->assertVisible('.controls button.link-audio.text-danger') ->click('.controls button.link-audio') ->assertAudioMuted('video', false) ->assertVisible('.controls button.link-audio:not(.text-danger)'); }); }); } /** * Test text chat * * @group openvidu * @depends testNicknameAndMuting */ public function testChat(): void { $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $owner, Browser $guest) { // Join the room as an owner $owner->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'john') ->click('@setup-button') ->waitFor('@session'); // In another browser act as a guest $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-status-message') ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->click('@setup-button') ->waitFor('@session'); // Test chat elements $owner->click('@menu button.link-chat') ->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED) ->assertVisible('@chat') ->assertVisible('@session') ->assertFocused('@chat-input') ->assertElementsCount('@chat-list .message', 0) ->keys('@chat-input', 'test1', '{enter}') ->assertValue('@chat-input', '') ->assertElementsCount('@chat-list .message', 1) ->assertSeeIn('@chat-list .message .nickname', 'john') ->assertSeeIn('@chat-list .message div:last-child', 'test1'); $guest->waitFor('@menu button.link-chat .badge') ->assertSeeIn('@menu button.link-chat .badge', '1') ->click('@menu button.link-chat') ->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED) ->assertMissing('@menu button.link-chat .badge') ->assertVisible('@chat') ->assertVisible('@session') ->assertElementsCount('@chat-list .message', 1) ->assertSeeIn('@chat-list .message .nickname', 'john') ->assertSeeIn('@chat-list .message div:last-child', 'test1'); // Test the number of (hidden) incoming messages $guest->click('@menu button.link-chat') ->assertMissing('@chat'); $owner->keys('@chat-input', 'test2', '{enter}', 'test3', '{enter}') ->assertElementsCount('@chat-list .message', 1) ->assertSeeIn('@chat-list .message .nickname', 'john') ->assertElementsCount('@chat-list .message div', 4) ->assertSeeIn('@chat-list .message div:last-child', 'test3'); $guest->waitFor('@menu button.link-chat .badge') ->assertSeeIn('@menu button.link-chat .badge', '2') ->click('@menu button.link-chat') ->assertElementsCount('@chat-list .message', 1) ->assertSeeIn('@chat-list .message .nickname', 'john') ->assertSeeIn('@chat-list .message div:last-child', 'test3') ->keys('@chat-input', 'guest1', '{enter}') ->assertElementsCount('@chat-list .message', 2) ->assertMissing('@chat-list .message:last-child .nickname') ->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1'); $owner->assertElementsCount('@chat-list .message', 2) ->assertMissing('@chat-list .message:last-child .nickname') ->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1'); // Test nickname change is propagated to chat messages $guest->setNickname('div.meet-video.publisher', 'guest') ->keys('@chat-input', 'guest2', '{enter}') ->assertElementsCount('@chat-list .message', 2) ->assertSeeIn('@chat-list .message:last-child .nickname', 'guest') ->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2'); $owner->assertElementsCount('@chat-list .message', 2) ->assertSeeIn('@chat-list .message:last-child .nickname', 'guest') ->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2'); // TODO: Test text chat features, e.g. link handling }); } /** * Test screen sharing * * @group openvidu */ public function testShareScreen(): void { // It looks that screen sharing API is not available in headless chrome // Note that other tests already assert that the button is disabled $this->markTestIncomplete(); } } diff --git a/src/tests/Browser/Meet/RoomSecurityTest.php b/src/tests/Browser/Meet/RoomSecurityTest.php new file mode 100644 index 00000000..9974c1dc --- /dev/null +++ b/src/tests/Browser/Meet/RoomSecurityTest.php @@ -0,0 +1,129 @@ +<?php + +namespace Tests\Browser\Meet; + +use App\OpenVidu\Room; +use Tests\Browser; +use Tests\Browser\Components\Dialog; +use Tests\Browser\Components\Toast; +use Tests\Browser\Pages\Meet\Room as RoomPage; +use Tests\TestCaseDusk; + +class RoomSecurityTest extends TestCaseDusk +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + $this->clearBetaEntitlements(); + $this->assignBetaEntitlement('john@kolab.org', 'meet'); + + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + } + + public function tearDown(): void + { + $this->clearBetaEntitlements(); + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + + parent::tearDown(); + } + + /** + * Test password protected room + * + * @group openvidu + */ + public function testRoomPassword(): 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') + ->assertMissing('@setup-password-input') + ->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('@button-action', 'Close') + ->assertElementsCount('.modal-footer button', 1) + ->assertSeeIn('#password-input .label', 'Password:') + ->assertSeeIn('#password-input-text.text-muted', 'none') + ->assertVisible('#password-input + small') + ->assertSeeIn('#password-set-btn', 'Set password') + ->assertElementsCount('#password-input button', 1) + ->assertMissing('#password-input input') + // Test setting a password + ->click('#password-set-btn') + ->assertMissing('#password-input-text') + ->assertVisible('#password-input input') + ->assertValue('#password-input input', '') + ->assertSeeIn('#password-input #password-save-btn', 'Save') + ->assertElementsCount('#password-input button', 1) + ->type('#password-input input', 'pass') + ->click('#password-input #password-save-btn') + ->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.') + ->assertMissing('#password-input input') + ->assertSeeIn('#password-input-text:not(.text-muted)', 'pass') + ->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password') + ->assertElementsCount('#password-input button', 1) + ->click('@button-action'); + + $this->assertSame('pass', $room->fresh()->getSetting('password')); + }); + + // In another browser act as a guest, expect password required + $guest->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertSeeIn('@setup-status-message.text-danger', "Please, provide a valid password.") + ->assertVisible('@setup-form .input-group:nth-child(4) svg') + ->assertAttribute('@setup-form .input-group:nth-child(4) .input-group-text', 'title', 'Password') + ->assertAttribute('@setup-password-input', 'placeholder', 'Password') + ->assertValue('@setup-password-input', '') + ->assertSeeIn('@setup-button', "JOIN") + // Try to join w/o password + ->click('@setup-button') + ->waitFor('#setup-password.is-invalid') + // Try to join with a valid password + ->type('#setup-password', 'pass') + ->click('@setup-button') + ->waitFor('@session'); + + // Test removing the password + $owner->click('@menu button.link-security') + ->with(new Dialog('#security-options-dialog'), function (Browser $browser) use ($room) { + $browser->assertSeeIn('@title', 'Security options') + ->assertSeeIn('#password-input-text:not(.text-muted)', 'pass') + ->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.') + ->assertMissing('#password-input input') + ->assertSeeIn('#password-input-text.text-muted', 'none') + ->assertSeeIn('#password-set-btn', 'Set password') + ->assertElementsCount('#password-input button', 1) + ->click('@button-action'); + + $this->assertSame(null, $room->fresh()->getSetting('password')); + }); + }); + } +} diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php index 3b968710..3864d666 100644 --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -1,276 +1,281 @@ <?php namespace Tests\Browser\Meet; use App\OpenVidu\Room; use Tests\Browser; use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Menu; use Tests\Browser\Pages\Meet\Room as RoomPage; use Tests\TestCaseDusk; class RoomSetupTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->clearBetaEntitlements(); } public function tearDown(): void { $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test non-existing room * * @group openvidu */ public function testRoomNonExistingRoom(): void { $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('unknown')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room does not exist.") ->assertMissing('@setup-button'); }); } /** * Test the room setup page * * @group openvidu */ public function testRoomSetup(): void { // Make sure there's no session yet $room = Room::where('name', 'john')->first(); if ($room->session_id) { $room->session_id = null; $room->save(); } $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('john')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } // Note: I've found out that if I have another Chrome instance running // that uses media, here the media devices will not be available // TODO: Test enabling/disabling cam/mic in the setup widget $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-title', 'Set up your session') ->assertVisible('@setup-video') - ->assertSeeIn('@setup-form .form-group:nth-child(1) label', 'Microphone') + ->assertVisible('@setup-form .input-group:nth-child(1) svg') + ->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone') ->assertVisible('@setup-mic-select') - ->assertSeeIn('@setup-form .form-group:nth-child(2) label', 'Camera') + ->assertVisible('@setup-form .input-group:nth-child(2) svg') + ->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera') ->assertVisible('@setup-cam-select') - ->assertSeeIn('@setup-form .form-group:nth-child(3) label', 'Nickname') + ->assertVisible('@setup-form .input-group:nth-child(3) svg') + ->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname') ->assertValue('@setup-nickname-input', '') + ->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name') + ->assertMissing('@setup-password-input') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); }); } /** * Test two users in a room (joining/leaving and some basic functionality) * * @group openvidu * @depends testRoomSetup */ public function testTwoUsersInARoom(): void { $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $browser, Browser $guest) { // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); // In another window join the room as the owner (authenticate) $browser->on(new RoomPage('john')) ->assertSeeIn('@setup-button', "I'm the owner") ->click('@setup-button') ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@setup-form') ->assertVisible('@login-form') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->assertMissing('@login-form') ->waitUntilMissing('@setup-status-message.loading') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->type('@setup-nickname-input', 'john') // Join the room ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') ->assertSeeIn('.nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']); }); } // After the owner "opened the room" guest should be able to join $guest->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') ->assertVisible('.nickname button') ->assertMissing('.nickname span') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertVisible('video') ->assertSeeIn('.nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($guest->isDesktop()) { $guest->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } // Check guest's elements in the owner's window $browser->waitFor('@session div.meet-video:nth-child(2)') ->assertElementsCount('@session div.meet-video', 2) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertMissing('video') ->assertMissing('.nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }); // Test leaving the room // Guest is leaving $guest->click('@menu button.link-logout') ->waitForLocation('/login'); // Expect the participant removed from other users windows $browser->waitUntilMissing('@session div.meet-video:nth-child(2)'); // Join the room as guest again $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-status-message') ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->click('@setup-button') ->waitFor('@session'); // Leave the room as the room owner // TODO: Test leaving the room by closing the browser window, // it should not destroy the session $browser->click('@menu button.link-logout') ->waitForLocation('/dashboard'); // Expect other participants be informed about the end of the session $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') ->click('@button-action'); }) ->assertMissing('#leave-dialog') ->waitForLocation('/login'); }); } } diff --git a/src/tests/Browser/Pages/Meet/Room.php b/src/tests/Browser/Pages/Meet/Room.php index 46ee3132..3b58b031 100644 --- a/src/tests/Browser/Pages/Meet/Room.php +++ b/src/tests/Browser/Pages/Meet/Room.php @@ -1,198 +1,199 @@ <?php namespace Tests\Browser\Pages\Meet; use Laravel\Dusk\Page; use PHPUnit\Framework\Assert; class Room extends Page { public const BUTTON_ACTIVE = 1; public const BUTTON_ENABLED = 2; public const BUTTON_INACTIVE = 4; public const BUTTON_DISABLED = 8; protected $roomName; /** * Object constructor. * * @param string $name Room name */ public function __construct($name) { $this->roomName = $name; } /** * Get the URL for the page. * * @return string */ public function url() { return '/meet/' . $this->roomName; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser) { $browser->waitForLocation($this->url()) ->waitUntilMissing('.app-loader') ->waitUntilMissing('#meet-setup div.status-message.loading'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', '@setup-form' => '#meet-setup form', '@setup-title' => '#meet-setup .card-title', '@setup-mic-select' => '#setup-microphone', '@setup-cam-select' => '#setup-camera', '@setup-nickname-input' => '#setup-nickname', + '@setup-password-input' => '#setup-password', '@setup-preview' => '#setup-preview', '@setup-volume' => '#setup-preview .volume', '@setup-video' => '#setup-preview video', '@setup-status-message' => '#meet-setup div.status-message', '@setup-button' => '#meet-setup form button', '@toolbar' => '#meet-session-toolbar', '@menu' => '#meet-session-menu', '@session' => '#meet-session', '@chat' => '#meet-chat', '@chat-input' => '#meet-chat textarea', '@chat-list' => '#meet-chat .chat', '@login-form' => '#meet-auth', '@login-email-input' => '#inputEmail', '@login-password-input' => '#inputPassword', '@login-second-factor-input' => '#secondfactor', '@login-button' => '#meet-auth button', ]; } /** * Assert menu state. * * @param \Tests\Browser $browser The browser object * @param array $menu Menu items/state */ public function assertToolbar($browser, array $menu): void { $browser->assertElementsCount('@menu button', count($menu)); foreach ($menu as $item => $state) { $this->assertToolbarButtonState($browser, $item, $state); } } /** * Assert menu button state. * * @param \Tests\Browser $browser The browser object * @param string $button Button name * @param int $state Expected button state (sum of BUTTON_* consts) */ public function assertToolbarButtonState($browser, $button, $state): void { $class = ''; if ($state & self::BUTTON_ACTIVE) { $class .= ':not(.text-danger)'; } if ($state & self::BUTTON_INACTIVE) { $class .= '.text-danger'; } if ($state & self::BUTTON_DISABLED) { $class .= '[disabled]'; } if ($state & self::BUTTON_ENABLED) { $class .= ':not([disabled])'; } $browser->assertVisible('@menu button.link-' . $button . $class); } /** * Assert the <video> element's 'muted' property state * * @param \Tests\Browser $browser The browser object * @param string $selector Video element selector * @param bool $state Expected state */ public function assertAudioMuted($browser, $selector, $state): void { $selector = addslashes($browser->resolver->format($selector)); $result = $browser->script( "var video = document.querySelector('$selector'); return video.muted" ); Assert::assertSame((bool) $result[0], $state); } /** * Set the nickname for the participant * * @param \Tests\Browser $browser The browser object * @param string $selector Participant element selector * @param string $nickname Nickname */ public function setNickname($browser, $selector, $nickname): void { // Use script() because type() does not work with this contenteditable widget $selector = $selector . ' .nickname span'; $browser->script( "var element = document.querySelector('$selector');" . "element.focus();" . "element.innerText = '$nickname';" . "element.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 }))" ); } /** * Submit logon form. * * @param \Tests\Browser $browser The browser object * @param string $username User name * @param string $password User password * @param array $config Client-site config */ public function submitLogon($browser, $username, $password, $config = []): void { $browser->type('@login-email-input', $username) ->type('@login-password-input', $password); if ($username == 'ned@kolab.org') { $code = \App\Auth\SecondFactor::code('ned@kolab.org'); $browser->type('@login-second-factor-input', $code); } if (!empty($config)) { $browser->script( sprintf('Object.assign(window.config, %s)', \json_encode($config)) ); } $browser->click('@login-button'); } } diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php index db52b28e..fc87cfde 100644 --- a/src/tests/Feature/Controller/OpenViduTest.php +++ b/src/tests/Feature/Controller/OpenViduTest.php @@ -1,202 +1,317 @@ <?php namespace Tests\Feature\Controller; use App\Http\Controllers\API\V4\OpenViduController; use App\OpenVidu\Room; use Tests\TestCase; class OpenViduTest extends TestCase { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); + $this->clearBetaEntitlements(); + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); } public function tearDown(): void { $this->clearBetaEntitlements(); + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + parent::tearDown(); } /** * Test listing user rooms * * @group openvidu */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); Room::where('user_id', $jack->id)->delete(); // Unauth access not allowed $response = $this->get("api/v4/openvidu/rooms"); $response->assertStatus(401); // John has one room $response = $this->actingAs($john)->get("api/v4/openvidu/rooms"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('john', $json['list'][0]['name']); // Jack has no room, but it will be auto-created $response = $this->actingAs($jack)->get("api/v4/openvidu/rooms"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertRegExp('/^[0-9a-z-]{11}$/', $json['list'][0]['name']); } /** * Test joining the room * * @group openvidu */ public function testJoinRoom(): 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'); // Unauth access, no session yet - $response = $this->get("api/v4/openvidu/rooms/{$room->name}"); + $response = $this->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(423); // Non-existing room name - $response = $this->actingAs($john)->get("api/v4/openvidu/rooms/non-existing"); + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing"); $response->assertStatus(404); // Non-owner, no session yet - $response = $this->actingAs($jack)->get("api/v4/openvidu/rooms/{$room->name}"); + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(423); // Room owner, no session yet - $response = $this->actingAs($john)->get("api/v4/openvidu/rooms/{$room->name}"); + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(424); - $response = $this->actingAs($john)->get("api/v4/openvidu/rooms/{$room->name}?init=1"); + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); $response->assertStatus(200); $json = $response->json(); $session_id = $room->fresh()->session_id; $this->assertSame('PUBLISHER', $json['role']); $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']; // Non-owner, now the session exists - $response = $this->actingAs($jack)->get("api/v4/openvidu/rooms/{$room->name}"); + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('PUBLISHER', $json['role']); $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']); + + // Non-owner, password protected room, password not provided + $room->setSettings(['password' => 'pass']); + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}"); + $response->assertStatus(425); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('Failed to join the session. Invalid password.', $json['message']); + $this->assertEmpty($json['config']['password']); + $this->assertTrue($json['config']['requires_password']); + + // Non-owner, password protected room, invalid provided + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['password' => 'aa']); + $response->assertStatus(425); + + // Non-owner, password protected room, valid password provided + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['password' => 'pass']); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($session_id, $json['session']); + + // 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 joining the room * * @group openvidu * @depends testJoinRoom */ public function testJoinRoomGuest(): void { $this->assignBetaEntitlement('john@kolab.org', 'meet'); // There's no asy way to logout the user in the same test after // using actingAs(). That's why this is moved to a separate test $room = Room::where('name', 'john')->first(); // Guest, request with screenShare token - $response = $this->get("api/v4/openvidu/rooms/{$room->name}?screenShare=1"); + $response = $this->post("api/v4/openvidu/rooms/{$room->name}", ['screenShare' => 1]); $response->assertStatus(200); $json = $response->json(); $this->assertSame('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']); } /** * Test closing the room (session) * * @group openvidu * @depends testJoinRoom */ public function testCloseRoom(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $room = Room::where('name', 'john')->first(); // Unauth access not allowed $response = $this->post("api/v4/openvidu/rooms/{$room->name}/close", []); $response->assertStatus(401); // Non-existing room name $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/close", []); $response->assertStatus(404); // Non-owner $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/close", []); $response->assertStatus(403); // Room owner $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []); $response->assertStatus(200); $json = $response->json(); $this->assertNull($room->fresh()->session_id); $this->assertSame('success', $json['status']); $this->assertSame("The session has been closed successfully.", $json['message']); $this->assertCount(2, $json); // TODO: Test if the session is removed from the OpenVidu server too // Test error handling when it's not possible to delete the session on // the OpenVidu server (use fake session_id) $room->session_id = 'aaa'; $room->save(); $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/close", []); $response->assertStatus(500); $json = $response->json(); $this->assertSame('aaa', $room->fresh()->session_id); $this->assertSame('error', $json['status']); $this->assertSame("Failed to close the session.", $json['message']); $this->assertCount(2, $json); } + + /** + * Test configuring the room (session) + * + * @group openvidu + */ + public function testSetRoomConfig(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $room = Room::where('name', 'john')->first(); + + // Unauth access not allowed + $response = $this->post("api/v4/openvidu/rooms/{$room->name}/config", []); + $response->assertStatus(401); + + // Non-existing room name + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/config", []); + $response->assertStatus(404); + + // TODO: Test a room with a deleted owner + + // Non-owner + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/config", []); + $response->assertStatus(403); + + // Room owner + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame("Room configuration updated successfully.", $json['message']); + + // Set password and room lock + $post = ['password' => 'aaa', 'locked' => 1]; + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame("Room configuration updated successfully.", $json['message']); + $room->refresh(); + $this->assertSame('aaa', $room->getSetting('password')); + $this->assertSame('true', $room->getSetting('locked')); + + // Unset password and room lock + $post = ['password' => '', 'locked' => 0]; + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame("Room configuration updated successfully.", $json['message']); + $room->refresh(); + $this->assertSame(null, $room->getSetting('password')); + $this->assertSame(null, $room->getSetting('locked')); + + // Test invalid option error + $post = ['password' => 'eee', 'unknown' => 0]; + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/config", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame("Invalid room configuration option.", $json['errors']['unknown']); + + $room->refresh(); + $this->assertSame(null, $room->getSetting('password')); + } }