diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -11,6 +11,68 @@ class OpenViduController extends Controller { /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function acceptJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestAccept($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-accept-error')); + } + + return response()->json(['status' => 'success']); + } + + /** + * Denying the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function denyJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestDeny($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-deny-error')); + } + + return response()->json(['status' => 'success']); + } + + /** * Close the room session. * * @param string $id Room identifier (name) @@ -44,6 +106,37 @@ } /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $conn Connection identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function dismissConnection($id, $conn) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->closeOVConnection($conn)) { + return $this->errorResponse(500, \trans('meet.session-dismiss-connection-error')); + } + + return response()->json(['status' => 'success']); + } + + /** * Listing of rooms that belong to the current user. * * @return \Illuminate\Http\JsonResponse @@ -136,19 +229,53 @@ if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { - // Note: We send the config to the client so it knows to display the password field - $response = [ - 'config' => $config, - 'message' => \trans('meet.session-password-error'), - 'status' => 'error', - ]; - - return response()->json($response, 425); + return $this->errorResponse(425, \trans('meet.session-password-error'), ['config' => $config]); + } + } + + // Handle locked room + if (!$isOwner && $config['locked']) { + $nickname = request()->input('nickname'); + $picture = request()->input('picture'); + $requestId = request()->input('requestId'); + + $request = $requestId ? $room->requestGet($requestId) : null; + + $error = \trans('meet.session-room-locked-error'); + + // Request already has been processed (not accepted yet, but it could be denied) + if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { + if (!$request) { + if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + if (empty($picture)) { + $svg = file_get_contents(resource_path('images/user.svg')); + $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); + } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // TODO: Resize when big/make safe the user picture? + + $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; + + if (!$room->requestSave($requestId, $request)) { + // FIXME: should we use error code 500? + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // Send the request (signal) to the owner + $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); + } + + return $this->errorResponse(427, $error, ['config' => $config]); } } // Create session token for the current user/connection - $response = $room->getSessionToken('PUBLISHER'); + $response = $room->getSessionToken($isOwner ? Room::ROLE_MODERATOR : Room::ROLE_PUBLISHER); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); @@ -156,7 +283,7 @@ // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { - $add_token = $room->getSessionToken('PUBLISHER'); + $add_token = $room->getSessionToken(Room::ROLE_PUBLISHER); $response['shareToken'] = $add_token['token']; } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -19,10 +19,11 @@ * * @param int $code Error code * @param string $message Error message + * @param array $data Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function errorResponse(int $code, string $message = null) + protected function errorResponse(int $code, string $message = null, array $data = []) { $errors = [ 400 => "Bad request", @@ -39,6 +40,10 @@ 'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"), ]; + if (!empty($data)) { + $response = $response + $data; + } + return response()->json($response, $code); } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -4,6 +4,7 @@ use App\Traits\SettingsTrait; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; /** * The eloquent definition of a Room. @@ -17,6 +18,13 @@ { use SettingsTrait; + public const ROLE_MODERATOR = 'MODERATOR'; + public const ROLE_PUBLISHER = 'PUBLISHER'; + public const ROLE_SUBSCRIBER = 'SUBSCRIBER'; + + public const REQUEST_ACCEPTED = 'accepted'; + public const REQUEST_DENIED = 'denied'; + protected $fillable = [ 'user_id', 'name' @@ -53,6 +61,54 @@ } /** + * Destroy a OpenVidu connection + * + * @param string $conn Connection identifier + * + * @return bool True on success, False otherwise + * @throws \Exception if session does not exist + */ + public function closeOVConnection($conn): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('DELETE', $url); + + return $response->getStatusCode() == 204; + } + + /** + * Fetch a OpenVidu connection information. + * + * @param string $conn Connection identifier + * + * @return ?array Connection data on success, Null otherwise + * @throws \Exception if session does not exist + */ + public function getOVConnection($conn): ?array + { + // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists + // TODO: Maybe use some other name? getParticipant? + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('GET', $url); + + if ($response->getStatusCode() == 200) { + return json_decode($response->getBody(), true); + } + + return null; + } + + /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise @@ -73,6 +129,7 @@ if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); + return null; } $session = json_decode($response->getBody(), true); @@ -114,7 +171,7 @@ * * @return array|null Token data on success, NULL otherwise */ - public function getSessionToken($role = 'PUBLISHER'): ?array + public function getSessionToken($role = self::ROLE_PUBLISHER): ?array { $response = $this->client()->request( 'POST', @@ -163,6 +220,75 @@ } /** + * Accept the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestAccept(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_ACCEPTED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Deny the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestDeny(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_DENIED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Get the join request data. + * + * @param string $id Request identifier + * + * @return array|null Request data (e.g. nickname, status, picture?) + */ + public function requestGet(string $id): ?array + { + return Cache::get($this->session_id . '-' . $id); + } + + /** + * Save the join request. + * + * @param string $id Request identifier + * @param array $request Request data + * + * @return bool True on success, False on failure + */ + public function requestSave(string $id, array $request): bool + { + // We don't really need the picture in the cache + // As we use this cache for the request status only + unset($request['picture']); + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany @@ -171,4 +297,65 @@ { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } + + /** + * Send a OpenVidu signal to the session participants (connections) + * + * @param string $name Signal name (type) + * @param array $data Signal data array + * @param array|string $target List of target connections, Null for all connections. + * It can be also a participant role. + * + * @return bool True on success, False on failure + * @throws \Exception if session does not exist + */ + public function signal(string $name, array $data = [], $target = null): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $post = [ + 'session' => $this->session_id, + 'type' => $name, + 'data' => $data ? json_encode($data) : '', + ]; + + // Get connection IDs by participant role + if (is_string($target)) { + // TODO: We should probably store this in our database/redis. I foresee a use-case + // for such a connections store on our side, e.g. keeping participant + // metadata, e.g. selected language, extra roles like a "language interpreter", etc. + + $response = $this->client()->request('GET', 'sessions/' . $this->session_id); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $json = json_decode($response->getBody(), true); + $connections = []; + + foreach ($json['connections']['content'] as $connection) { + if ($connection['role'] === $target) { + $connections[] = $connection['id']; + break; + } + } + + if (empty($connections)) { + return false; + } + + $target = $connections; + } + + if (!empty($target)) { + $post['to'] = $target; + } + + $response = $this->client()->request('POST', 'signal', ['json' => $post]); + + return $response->getStatusCode() == 200; + } } diff --git a/src/resources/images/user.svg b/src/resources/images/user.svg new file mode 100644 --- /dev/null +++ b/src/resources/images/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -14,6 +14,7 @@ let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata + let role // Current user role let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing @@ -70,7 +71,7 @@ * Join the room session * * @param data Session metadata and event handlers (session, token, shareToken, nickname, - * chatElement, menuElement, onDestroy) + * chatElement, menuElement, onDestroy, onJoinRequest) */ function joinRoom(data) { resize(); @@ -90,6 +91,7 @@ session.on('connectionCreated', event => { // Ignore the current user connection if (event.connection.role) { + role = event.connection.role return } @@ -105,6 +107,7 @@ let connectionId = event.connection.connectionId let metadata = JSON.parse(event.connection.data) + metadata.connId = connectionId let wrapper = videoWrapperCreate(container, metadata) connections[connectionId] = { @@ -414,7 +417,7 @@ */ function signalEventHandler(signal) { let conn, data - let connId = signal.from.connectionId + let connId = signal.from ? signal.from.connectionId : null switch (signal.type) { case 'signal:userChanged': @@ -431,6 +434,12 @@ data.id = connId pushChatMessage(data) break + + case 'signal:joinRequest': + if (sessionData.onJoinRequest) { + sessionData.onJoinRequest(JSON.parse(signal.data)) + } + break; } } @@ -645,26 +654,31 @@ function videoWrapperCreate(container, params) { // Create the element let wrapper = $('