diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index 7d14befb..9bba87a4 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,409 +1,460 @@ 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) { + // Only the moderator can do it + if (!$this->isModerator($room)) { 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. + * Deny 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) { + // Only the moderator can do it + if (!$this->isModerator($room)) { return $this->errorResponse(403); } if (!$room->requestDeny($reqid)) { return $this->errorResponse(500, \trans('meet.session-request-deny-error')); } return response()->json(['status' => 'success']); } /** * Close the room session. * * @param string $id Room identifier (name) * * @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'), ]); } /** - * Accepting the room join request. + * Dismiss the participant/connection from the session. * * @param string $id Room identifier (name) * @param string $conn Connection identifier * * @return \Illuminate\Http\JsonResponse */ public function dismissConnection($id, $conn) { $connection = Connection::where('id', $conn)->first(); // There's no such connection, bye bye if (!$connection || $connection->room->name != $id) { return $this->errorResponse(404, \trans('meet.connection-not-found')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it (for now) - if (!$user || $user->id != $connection->room->user_id) { + // Only the moderator can do it + if (!$this->isModerator($connection->room)) { return $this->errorResponse(403); } if (!$connection->dismiss()) { return $this->errorResponse(500, \trans('meet.connection-dismiss-error')); } return response()->json(['status' => '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; $init = !empty(request()->input('init')); // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner if (!$isOwner) { return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]); } // The room owner can create the session on request if (!$init) { return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]); } $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), ]; $response = ['config' => $config]; // Validate room password if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]); } } // Handle locked room if (!$isOwner && $config['locked']) { $nickname = request()->input('nickname'); $picture = request()->input('picture'); $requestId = request()->input('requestId'); $request = $requestId ? $room->requestGet($requestId) : null; $error = \trans('meet.session-room-locked-error'); // Request already has been processed (not accepted yet, but it could be denied) if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { if (!$request) { if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } if (empty($picture)) { $svg = file_get_contents(resource_path('images/user.svg')); $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } // TODO: Resize when big/make safe the user picture? $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; if (!$room->requestSave($requestId, $request)) { // FIXME: should we use error code 500? return $this->errorResponse(422, $error, $response + ['code' => 326]); } // Send the request (signal) to the owner $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); } return $this->errorResponse(422, $error, $response + ['code' => 327]); } } // Initialize connection tokens if ($init) { // Choose the connection role $canPublish = !empty(request()->input('canPublish')); $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER; if ($isOwner) { $role |= Room::ROLE_MODERATOR; $role |= Room::ROLE_OWNER; } // Create session token for the current user/connection $response = $room->getSessionToken($role); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); } // Create session token for screen sharing connection if (($role & Room::ROLE_PUBLISHER) && !empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken(Room::ROLE_SCREEN); $response['shareToken'] = $add_token['token']; } $response_code = 200; $response['role'] = $role; $response['config'] = $config; } else { $response_code = 422; $response['code'] = 322; } return response()->json($response, $response_code); } /** * 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'), ]); } + /** + * Update the participant/connection parameters (e.g. role). + * + * @param string $id Room identifier (name) + * @param string $conn Connection identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function updateConnection($id, $conn) + { + $connection = Connection::where('id', $conn)->first(); + + // There's no such connection, bye bye + if (!$connection || $connection->room->name != $id) { + return $this->errorResponse(404, \trans('meet.connection-not-found')); + } + + // Only the moderator can do it + if (!$this->isModerator($connection->room)) { + return $this->errorResponse(403); + } + + foreach (request()->input() as $key => $value) { + switch ($key) { + case 'role': + $connection->{$key} = $value; + break; + } + } + + // The connection observer will send a signal to everyone when needed + $connection->save(); + + return response()->json(['status' => '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(); } // Remove all connections // Note: We could remove connections one-by-one via the 'participantLeft' event // but that could create many INSERTs when the session (with many participants) ends // So, it is better to remove them all in a single INSERT. Connection::where('session_id', $sessionId)->delete(); break; } return response('Success', 200); } + + /** + * Check if current user is a moderator for the specified room. + * + * @param \App\OpenVidu\Room $room The room + * + * @return bool True if the current user is the room moderator + */ + protected function isModerator(Room $room): bool + { + $user = Auth::guard()->user(); + + // The room owner is a moderator + if ($user && $user->id == $room->user_id) { + return true; + } + + // TODO: Moderators authentication + + return false; + } } diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php new file mode 100644 index 00000000..57a86c4d --- /dev/null +++ b/src/app/Observers/OpenVidu/ConnectionObserver.php @@ -0,0 +1,32 @@ +role != $connection->getOriginal('role')) { + $params = [ + 'connectionId' => $connection->id, + 'role' => $connection->role + ]; + + // Send the signal to all participants + $connection->room->signal('connectionUpdate', $params); + + // TODO: When demoting publisher to subscriber maybe we should + // destroy all streams using REST API. For now we trust the + // participant browser to do this. + } + } +} diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php index ba8950bc..d5c3bc92 100644 --- a/src/app/OpenVidu/Connection.php +++ b/src/app/OpenVidu/Connection.php @@ -1,57 +1,88 @@ 'array', ]; /** * Dismiss (close) the connection. * * @return bool True on success, False on failure */ public function dismiss() { if ($this->room->closeOVConnection($this->id)) { $this->delete(); return true; } return false; } /** * The room to which this connection belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function room() { return $this->belongsTo(Room::class, 'room_id', 'id'); } + + /** + * Connection role mutator + * + * @throws \Exception + */ + public function setRoleAttribute($role) + { + $new_role = 0; + + $allowed_values = [ + Room::ROLE_SUBSCRIBER, + Room::ROLE_PUBLISHER, + Room::ROLE_MODERATOR, + Room::ROLE_SCREEN, + Room::ROLE_OWNER, + ]; + + foreach ($allowed_values as $value) { + if ($role & $value) { + $new_role |= $value; + $role ^= $value; + } + } + + if ($role > 0) { + throw new \Exception("Invalid connection role: {$role}"); + } + + $this->attributes['role'] = $new_role; + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 2209bbf2..40437600 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,60 +1,61 @@ sql, implode(', ', $query->bindings))); }); } // Register some template helpers Blade::directive('theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; }); } } diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js index db3c0781..76945286 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,1078 +1,1205 @@ import anchorme from 'anchorme' import { library } from '@fortawesome/fontawesome-svg-core' import { OpenVidu } from 'openvidu-browser' class Roles { static get SUBSCRIBER() { return 1 << 0; } static get PUBLISHER() { return 1 << 1; } static get MODERATOR() { return 1 << 2; } static get SCREEN() { return 1 << 3; } static get OWNER() { return 1 << 4; } } function Meet(container) { let OV // OpenVidu object to initialize a session let session // Session object where the user will connect let publisher // Publisher object which the user will publish let audioActive = false // True if the audio track of the publisher is active let videoActive = false // True if the video track of the publisher is active - let numOfVideos = 0 // Keeps track of the number of videos that are being shown let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing let screenPublisher // Publisher object which the user will publish the screen sharing let publisherDefaults = { publishAudio: true, // Whether to start publishing with your audio unmuted or not publishVideo: true, // Whether to start publishing with your video enabled or not resolution: '640x480', // The resolution of your video frameRate: 30, // The frame rate of your video mirror: true // Whether to mirror your local video or not } let cameras = [] // List of user video devices let microphones = [] // List of user audio devices let connections = {} // Connected users in the session let containerWidth let containerHeight let chatCount = 0 let volumeElement let setupProps let subscribersContainer OV = new OpenVidu() screenOV = new OpenVidu() // If there's anything to do, do it here. //OV.setAdvancedConfiguration(config) // Disable all logging except errors // OV.enableProdMode() // Disconnect participant when browser's window close window.addEventListener('beforeunload', () => { leaveRoom() }) window.addEventListener('resize', resize) // Public methods this.isScreenSharingSupported = isScreenSharingSupported this.joinRoom = joinRoom this.leaveRoom = leaveRoom this.setup = setup this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchScreen = switchScreen this.switchVideo = switchVideo this.updateSession = updateSession /** * Join the room session * - * @param data Session metadata and event handlers (session, token, shareToken, nickname, role, - * chatElement, menuElement, onDestroy, onJoinRequest) + * @param data Session metadata and event handlers (token, shareToken, nickname, role, + * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, + * onSessionDataUpdate) */ function joinRoom(data) { resize(); volumeMeterStop() data.params = { nickname: data.nickname, // user nickname // avatar: undefined // avatar image } // Create a container for subscribers if (!subscribersContainer) { subscribersContainer = $('