Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117853510
D1912.1775315073.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
61 KB
Referenced Files
None
Subscribers
None
D1912.1775315073.diff
View Options
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 96c48.6 0 88 39.4 88 88s-39.4 88-88 88-88-39.4-88-88 39.4-88 88-88zm0 344c-58.7 0-111.3-26.6-146.5-68.2 18.8-35.4 55.6-59.8 98.5-59.8 2.4 0 4.8.4 7.1 1.1 13 4.2 26.6 6.9 40.9 6.9 14.3 0 28-2.7 40.9-6.9 2.3-.7 4.7-1.1 7.1-1.1 42.9 0 79.7 24.4 98.5 59.8C359.3 421.4 306.7 448 248 448z"/></svg>
\ 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 = $('<div class="meet-video">').html(
- `${svgIcon("user", 'fas', 'watermark')}
- <div class="nickname" title="Nickname">
- <span></span>
- <button type="button" class="btn btn-link">${svgIcon('user')}</button>
- </div>
- <div class="controls">
- <button type="button" class="btn btn-link link-audio hidden" title="Mute audio">${svgIcon('volume-mute')}</button>
- <button type="button" class="btn btn-link link-fullscreen closed hidden" title="Full screen">${svgIcon('expand')}</button>
- <button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">${svgIcon('compress')}</button>
- </div>
- <div class="status">
- <span class="bg-danger status-audio hidden">${svgIcon('microphone')}</span>
- <span class="bg-danger status-video hidden">${svgIcon('video')}</span>
- </div>`
+ svgIcon('user', 'fas', 'watermark')
+ + '<div class="dropdown">'
+ + '<a href="#" class="nickname btn btn-link" title="Nickname" aria-haspopup="true" aria-expanded="false" role="button">'
+ + '<span class="content"></span>'
+ + '<span class="icon">' + svgIcon('user') + '</span>'
+ + '</a>'
+ + '<div class="dropdown-menu">'
+ + '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ + '</div>'
+ + '</div>'
+ + '<div class="controls">'
+ + '<button type="button" class="btn btn-link link-audio hidden" title="Mute audio">' + svgIcon('volume-mute') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="Full screen">' + svgIcon('expand') + '</button>'
+ + '<button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">' + svgIcon('compress') + '</button>'
+ + '</div>'
+ + '<div class="status">'
+ + '<span class="bg-danger status-audio hidden">' + svgIcon('microphone') + '</span>'
+ + '<span class="bg-danger status-video hidden">' + svgIcon('video') + '</span>'
+ + '</div>'
)
if (params.publisher) {
// Add events for nickname change
let nickname = wrapper.addClass('publisher').find('.nickname')
- let editable = nickname.find('span').get(0)
+ let editable = nickname.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
@@ -688,14 +702,24 @@
}
})
} else {
- wrapper.find('.nickname > svg').addClass('hidden')
-
wrapper.find('.link-audio').removeClass('hidden')
.on('click', e => {
let video = wrapper.find('video')[0]
video.muted = !video.muted
wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger')
})
+
+ if (role == 'MODERATOR') {
+ wrapper.addClass('moderated')
+
+ wrapper.find('.nickname').attr({title: 'Options', 'data-toggle': 'dropdown'}).dropdown()
+
+ wrapper.find('.action-dismiss').on('click', e => {
+ if (sessionData.onDismiss) {
+ sessionData.onDismiss(params.connId)
+ }
+ })
+ }
}
videoWrapperUpdate(wrapper, params)
@@ -741,7 +765,7 @@
}
if ('nickname' in params) {
- $(wrapper).find('.nickname > span').text(params.nickname)
+ $(wrapper).find('.nickname > .content').text(params.nickname)
}
}
diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php
--- a/src/resources/lang/en/meet.php
+++ b/src/resources/lang/en/meet.php
@@ -21,6 +21,9 @@
'session-join-error' => 'Failed to join the session.',
'session-close-error' => 'Failed to close the session.',
'session-close-success' => 'The session has been closed successfully.',
+ 'session-dismiss-connection-error' => 'Failed to dismiss the connection.',
'session-password-error' => 'Failed to join the session. Invalid password.',
-
+ 'session-request-accept-error' => 'Failed to accept the join request.',
+ 'session-request-deny-error' => 'Failed to deny the join request.',
+ 'session-room-locked-error' => 'Failed to join the session. Room locked.',
];
diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss
--- a/src/resources/themes/meet.scss
+++ b/src/resources/themes/meet.scss
@@ -6,12 +6,6 @@
align-items: center;
justify-content: center;
- .watermark {
- color: darken($menu-bg-color, 20%);
- width: 50%;
- height: 50%;
- }
-
video {
// To make object-fit:cover working we have to set the height in pixels
// on the wrapper element. This is what javascript method will do.
@@ -33,6 +27,19 @@
}
}
+ .watermark {
+ color: darken($menu-bg-color, 20%);
+ width: 50%;
+ height: 50%;
+ }
+
+ .dropdown {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ }
+
.controls {
position: absolute;
bottom: 0;
@@ -78,41 +85,53 @@
border-radius: 1em;
max-width: calc(100% - 1em);
background: rgba(#fff, 0.8);
+ color: $body-color;
+ text-decoration: none !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ display: flex;
- button {
+ .icon {
display: none;
+ width: 2em;
+ margin: 0 -1em;
}
- span {
+ .content {
outline: none;
}
}
+ &.moderated .nickname {
+ display: flex;
+
+ .content {
+ order: 1;
+
+ &:not(:empty) + .icon {
+ margin-right: 0;
+ }
+ }
+
+ .icon {
+ display: inline-block;
+ }
+ }
+
&.publisher .nickname {
- cursor: pointer;
background: rgba($main-color, 0.9);
&:focus-within {
box-shadow: $btn-focus-box-shadow;
}
- span:empty {
+ .content:empty {
display: block;
height: 2em;
- &:not(:focus) + button {
- display: block;
- position: absolute;
- top: 0;
- left: 0;
- width: 2em;
- height: 2em;
- border-radius: 50%;
- padding: 0;
- color: $menu-gray;
+ &:not(:focus) + .icon {
+ display: inline-block;
}
}
}
@@ -253,3 +272,29 @@
}
}
}
+
+.toast.join-request {
+ .toast-header {
+ color: #eee;
+ }
+
+ .toast-body {
+ display: flex;
+ }
+
+ .picture {
+ margin-right: 1em;
+
+ img {
+ width: 64px;
+ height: 64px;
+ border: 1px solid #555;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+ }
+
+ .content {
+ flex: 1;
+ }
+}
diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss
--- a/src/resources/themes/toast.scss
+++ b/src/resources/themes/toast.scss
@@ -4,6 +4,10 @@
right: 0;
margin: 0.5rem;
width: 320px;
+ max-height: calc(100% - 1rem);
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(52, 58, 64, 0.95) transparent;
z-index: 1055; // above Bootstrap's modal backdrop and dialogs
@media (max-width: 375px) {
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -70,16 +70,15 @@
<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"
+ <button 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>
+ :disabled="roomState == 'init' || roomState == 427 || roomState == 404"
+ :class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
+ >
+ <span v-if="isRoomReady()">JOIN NOW</span>
+ <span v-else-if="roomState == 423">I'm the owner</span>
+ <span v-else>JOIN</span>
+ </button>
</div>
</div>
<div class="mt-4 col-sm-12">
@@ -157,6 +156,8 @@
423: 'The room is closed. Please, wait for the owner to start the session.',
424: 'The room is closed. It will be open for others after you join.',
425: 'The room is ready. Please, provide a valid password.',
+ 426: 'The room is locked. Please, enter your name and try again.',
+ 427: 'Waiting for permission to join the room.',
500: 'Failed to create a session. Server error.'
},
session: {}
@@ -184,8 +185,7 @@
},
methods: {
authSuccess() {
- // The user (owner) authentication succeeded
- this.roomState = 'init'
+ // The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
@@ -194,15 +194,20 @@
configUpdate(config) {
this.session.config = Object.assign({}, this.session.config, config)
},
+ dismissParticipant(id) {
+ axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss')
+ },
initSession(init) {
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
- init: init ? 1 : 0
+ init: init ? 1 : 0,
+ picture: init ? this.makePicture() : '',
+ requestId: this.requestId()
}
- $('#setup-password').removeClass('is-invalid')
+ $('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
@@ -234,6 +239,19 @@
$('#setup-password').addClass('is-invalid').focus()
}
break;
+
+ case '426':
+ // Locked room prerequisites error
+ if (init && !$('#setup-nickname').val()) {
+ $('#setup-nickname').addClass('is-invalid').focus()
+ }
+ break;
+
+ case '427':
+ // Waiting for the owner's approval to join
+ // Update room state every 10 seconds
+ window.roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
+ break;
}
})
@@ -241,6 +259,55 @@
$('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden')
}
},
+ isRoomReady() {
+ return ['ready', '424', '425', '426', '427'].includes(this.roomState)
+ },
+ // An event received by the room owner when a participant is asking for a permission to join the room
+ joinRequest(data) {
+ // The toast for this user request already exists, ignore
+ // It's not really needed as we do this on server-side already
+ if ($('#i' + data.requestId).length) {
+ return
+ }
+
+ // FIXME: Should the message close button act as the Deny button? Do we need the Deny button?
+
+ let body = $(
+ `<div>`
+ + `<div class="picture"><img src="${data.picture}"></div>`
+ + `<div class="content">`
+ + `<p class="mb-2"></p>`
+ + `<div class="text-right">`
+ + `<button type="button" class="btn btn-sm btn-success accept">Accept</button>`
+ + `<button type="button" class="btn btn-sm btn-danger deny ml-2">Deny</button>`
+ )
+
+ this.$toast.message({
+ className: 'join-request',
+ icon: 'user',
+ timeout: 0,
+ title: 'Join request',
+ // titleClassName: '',
+ body: body.html(),
+ onShow: element => {
+ const id = data.requestId
+
+ $(element).find('p').text((data.nickname || '') + ' requested to join.')
+
+ // add id attribute, so we can identify it
+ $(element).attr('id', 'i' + id)
+ // add action to the buttons
+ .find('button.accept,button.deny').on('click', e => {
+ const action = $(e.target).is('.accept') ? 'accept' : 'deny'
+ axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action)
+ .then(response => {
+ $('#i' + id).remove()
+ })
+ })
+ }
+ })
+ },
+ // Entering the room
joinSession() {
if (this.roomState == 423) {
$('#meet-setup').addClass('hidden')
@@ -248,7 +315,7 @@
return
}
- if (this.roomState == 424 || this.roomState == 425) {
+ if (this.roomState != 'ready') {
this.initSession(true)
return
}
@@ -267,9 +334,9 @@
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
this.session.onDestroy = event => {
- // TODO: Handle nicely other reasons: disconnect, forceDisconnectByUser,
- // forceDisconnectByServer, networkDisconnect?
- if (event.reason == 'sessionClosedByServer' && !this.session.owner) {
+ // TODO: Display different message for each reason: forceDisconnectByUser,
+ // forceDisconnectByServer, sessionClosedByServer?
+ if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.session.owner) {
$('#leave-dialog').on('hide.bs.modal', () => {
// FIXME: Where exactly the user should land? Currently he'll land
// on dashboard (if he's logged in) or login form (if he's not).
@@ -279,21 +346,72 @@
}
}
+ this.session.onDismiss = connId => { this.dismissParticipant(connId) }
+
+ if (this.session.owner) {
+ this.session.onJoinRequest = data => { this.joinRequest(data) }
+ }
+
this.meet.joinRoom(this.session)
},
logout() {
- if (this.session.owner) {
- axios.post('/api/v4/openvidu/rooms/' + this.room + '/close')
- .then(response => {
- this.meet.leaveRoom()
- this.meet = null
- window.location = window.config['app.url']
- })
- } else {
+ const logout = () => {
this.meet.leaveRoom()
this.meet = null
window.location = window.config['app.url']
}
+
+ if (this.session.owner) {
+ axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
+ } else {
+ logout()
+ }
+ },
+ makePicture() {
+ const video = $("#setup-preview video")[0];
+
+ // Skip if video is not "playing"
+ if (!video.videoWidth || !this.camera) {
+ return ''
+ }
+
+ // we're going to crop a square from the video and resize it
+ const maxSize = 64
+
+ // Calculate sizing
+ let sh = Math.floor(video.videoHeight / 1.5)
+ let sw = sh
+ let sx = (video.videoWidth - sw) / 2
+ let sy = (video.videoHeight - sh) / 2
+
+ let dh = Math.min(sh, maxSize)
+ let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh)
+
+ const canvas = $("<canvas>")[0];
+ canvas.width = dw;
+ canvas.height = dh;
+
+ // draw the image on the canvas (square cropped and resized)
+ canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh);
+
+ // convert it to a usable data URL (png format)
+ return canvas.toDataURL();
+ },
+ requestId() {
+ if (!this.reqId) {
+ // FIXME: Shall we use some UUID generator? Or better something that identifies the
+ // user/browser so we could deny the join request for a longer time.
+ // I'm thinking about e.g. a bad actor knocking again and again and again,
+ // we don't want the room owner to be bothered every few seconds.
+ // Maybe a solution would be to store the identifier in the browser storage
+ // This would not prevent hackers from sending the new identifier on every request,
+ // but could make sure that it is kept after page refresh for the avg user.
+
+ // This will create max. 24-char numeric string
+ this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24)
+ }
+
+ return this.reqId
},
securityOptions() {
$('#security-options-dialog').modal()
diff --git a/src/resources/vue/Meet/SessionSecurityOptions.vue b/src/resources/vue/Meet/SessionSecurityOptions.vue
--- a/src/resources/vue/Meet/SessionSecurityOptions.vue
+++ b/src/resources/vue/Meet/SessionSecurityOptions.vue
@@ -27,11 +27,11 @@
the password before they are allowed to join the meeting.
</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">
+ <hr>
+ <form id="security-options-lock">
+ <div id="room-lock">
+ <label for="room-lock-input">Locked room:</label>
+ <input type="checkbox" id="room-lock-input" 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
diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue
--- a/src/resources/vue/Widgets/Toast.vue
+++ b/src/resources/vue/Widgets/Toast.vue
@@ -82,6 +82,16 @@
}
return this.addToast(data)
+ },
+ message(data) {
+ if (data.type === undefined) {
+ data.type = 'custom'
+ }
+ if (data.timeout === undefined) {
+ data.timeout = this.defaultTimeout
+ }
+
+ return this.addToast(data)
}
},
// Plugin installer method
diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue
--- a/src/resources/vue/Widgets/ToastMessage.vue
+++ b/src/resources/vue/Widgets/ToastMessage.vue
@@ -1,18 +1,18 @@
<template>
- <div :class="'toast hide toast-' + data.type" role="alert" aria-live="assertive" aria-atomic="true">
- <div class="toast-header">
- <svg-icon icon="info-circle" :class="className()" v-if="data.type == 'info'"></svg-icon>
- <svg-icon icon="check-circle" :class="className()" v-else-if="data.type == 'success'"></svg-icon>
- <svg-icon icon="exclamation-circle" :class="className()" v-else-if="data.type == 'error'"></svg-icon>
- <svg-icon icon="exclamation-circle" :class="className()" v-else-if="data.type == 'warning'"></svg-icon>
- <strong :class="className()">{{ data.title || title() }}</strong>
+ <div :class="toastClassName()" role="alert" aria-live="assertive" aria-atomic="true">
+ <div class="toast-header" :class="className()">
+ <svg-icon icon="info-circle" v-if="data.type == 'info'"></svg-icon>
+ <svg-icon icon="check-circle" v-else-if="data.type == 'success'"></svg-icon>
+ <svg-icon icon="exclamation-circle" v-else-if="data.type == 'error'"></svg-icon>
+ <svg-icon icon="exclamation-circle" v-else-if="data.type == 'warning'"></svg-icon>
+ <svg-icon :icon="data.icon" v-else-if="data.type == 'custom' && data.icon"></svg-icon>
+ <strong>{{ data.title || title() }}</strong>
<button type="button" class="close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
- <div class="toast-body">
- {{ data.msg }}
- </div>
+ <div v-if="data.body" v-html="data.body" class="toast-body"></div>
+ <div v-else class="toast-body">{{ data.msg }}</div>
</div>
</template>
@@ -22,13 +22,19 @@
data: { type: Object, default: () => {} }
},
mounted() {
- $(this.$el).on('hidden.bs.toast', () => {
+ $(this.$el)
+ .on('hidden.bs.toast', () => {
(this.$el).remove()
this.$destroy()
})
+ .on('shown.bs.toast', () => {
+ if (this.data.onShow) {
+ this.data.onShow(this.$el)
+ }
+ })
.toast({
animation: true,
- autohide: true,
+ autohide: this.data.timeout > 0,
delay: this.data.timeout
})
.toast('show')
@@ -42,6 +48,8 @@
case 'info':
case 'success':
return 'text-' + this.data.type
+ case 'custom':
+ return this.data.titleClassName || ''
}
},
title() {
@@ -54,6 +62,12 @@
case 'success':
return type.charAt(0).toUpperCase() + type.slice(1)
}
+
+ return ''
+ },
+ toastClassName() {
+ return 'toast hide toast-' + this.data.type
+ + (this.data.className ? ' ' + this.data.className : '')
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -84,6 +84,10 @@
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
+ // FIXME: I'm not sure about this one, should we use DELETE request maybe?
+ Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
+ Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
+ Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -13,6 +13,19 @@
class Browser extends \Laravel\Dusk\Browser
{
/**
+ * Assert that the given element attribute contains specified text.
+ */
+ public function assertAttributeRegExp($selector, $attribute, $regexp)
+ {
+ $element = $this->resolver->findOrFail($selector);
+ $value = (string) $element->getAttribute($attribute);
+
+ Assert::assertRegExp($regexp, $value, "No expected text in [$selector][$attribute]. Found: $value");
+
+ return $this;
+ }
+
+ /**
* Assert number of (visible) elements
*/
public function assertElementsCount($selector, $expected_count, $visible = true)
diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php
--- a/src/tests/Browser/Components/Toast.php
+++ b/src/tests/Browser/Components/Toast.php
@@ -12,6 +12,7 @@
public const TYPE_SUCCESS = 'success';
public const TYPE_WARNING = 'warning';
public const TYPE_INFO = 'info';
+ public const TYPE_CUSTOM = 'custom';
protected $type;
protected $element;
diff --git a/src/tests/Browser/Meet/RoomSecurityTest.php b/src/tests/Browser/Meet/RoomSecurityTest.php
--- a/src/tests/Browser/Meet/RoomSecurityTest.php
+++ b/src/tests/Browser/Meet/RoomSecurityTest.php
@@ -115,7 +115,7 @@
->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password')
->assertElementsCount('#password-input button', 1)
->click('#password-clear-btn')
- ->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->assertMissing('#password-input input')
->assertSeeIn('#password-input-text.text-muted', 'none')
->assertSeeIn('#password-set-btn', 'Set password')
@@ -126,4 +126,123 @@
});
});
}
+
+ /**
+ * Test locked room
+ *
+ * @group openvidu
+ */
+ public function testLockedRoom(): void
+ {
+ $this->browse(function (Browser $owner, Browser $guest) {
+ // Make sure there's no session yet
+ $room = Room::where('name', 'john')->first();
+ if ($room->session_id) {
+ $room->session_id = null;
+ $room->save();
+ }
+
+ // Join the room as an owner (authenticate)
+ $owner->visit(new RoomPage('john'))
+ // ->click('@setup-button')
+ // ->submitLogon('john@kolab.org', 'simple123')
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->type('@setup-nickname-input', 'John')
+ ->click('@setup-button')
+ ->waitFor('@session')
+ // Enter Security option dialog
+ ->click('@menu button.link-security')
+ ->with(new Dialog('#security-options-dialog'), function (Browser $browser) use ($room) {
+ $browser->assertSeeIn('@title', 'Security options')
+ ->assertSeeIn('#room-lock label', 'Locked room:')
+ ->assertVisible('#room-lock input[type=checkbox]:not(:checked)')
+ ->assertVisible('#room-lock + small')
+ // Test setting the lock
+ ->click('#room-lock input')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
+ ->click('@button-action');
+
+ $this->assertSame('true', $room->fresh()->getSetting('locked'));
+ });
+
+ // In another browser act as a guest
+ $guest->visit(new RoomPage('john'))
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->assertSeeIn('@setup-button:not([disabled]).btn-success', 'JOIN NOW')
+ // try without the nickname
+ ->click('@setup-button')
+ ->waitFor('@setup-nickname-input.is-invalid')
+ ->assertSeeIn(
+ '@setup-status-message.text-danger',
+ "The room is locked. Please, enter your name and try again."
+ )
+ ->assertMissing('@setup-password-input')
+ ->assertSeeIn('@setup-button:not([disabled]).btn-success', 'JOIN NOW')
+ ->type('@setup-nickname-input', 'Guest<p>')
+ ->click('@setup-button')
+ ->assertMissing('@setup-nickname-input.is-invalid')
+ ->waitFor('@setup-button[disabled]')
+ ->assertSeeIn('@setup-status-message.text-danger', "Waiting for permission to join the room.");
+
+ // Test denying the request (this will also test custom toasts)
+ $owner
+ ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
+ $browser->assertToastTitle('Join request')
+ ->assertVisible('.toast-header svg.fa-user')
+ ->assertSeeIn('@message', 'Guest<p> requested to join.')
+ ->assertAttributeRegExp('@message img', 'src', '|^data:image|')
+ ->assertSeeIn('@message button.accept.btn-success', 'Accept')
+ ->assertSeeIn('@message button.deny.btn-danger', 'Deny')
+ ->click('@message button.deny');
+ })
+ ->waitUntilMissing('.toast')
+ // wait 10 seconds to make sure the request message does not show up again
+ ->pause(10 * 1000)
+ ->assertMissing('.toast');
+
+ // Test accepting the request
+ $guest->refresh()
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->type('@setup-nickname-input', 'guest')
+ ->click('@setup-button')
+ ->waitFor('@setup-button[disabled]')
+ ->assertSeeIn('@setup-status-message.text-danger', "Waiting for permission to join the room.");
+
+ $owner
+ ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
+ $browser->assertToastTitle('Join request')
+ ->assertSeeIn('@message', 'guest requested to join.')
+ ->click('@message button.accept');
+ });
+
+ // Guest automatically anters the room
+ $guest->waitFor('@session', 12)
+ // make sure he has no access to the Options menu
+ ->waitFor('@session .meet-video:not(.publisher)')
+ ->assertSeeIn('@session .meet-video:not(.publisher) a.nickname', 'John')
+ // TODO: Assert title and icon
+ ->click('@session .meet-video:not(.publisher) a.nickname')
+ ->pause(100)
+ ->assertMissing('.dropdown-menu');
+
+ // Test dismissing the participant
+ $owner->click('@session .meet-video:not(.publisher) a.nickname')
+ ->waitFor('@session .meet-video:not(.publisher) .dropdown-menu')
+ ->assertSeeIn('@session .meet-video:not(.publisher) .dropdown-menu > .action-dismiss', 'Dismiss')
+ ->click('@session .meet-video:not(.publisher) .dropdown-menu > .action-dismiss')
+ ->waitUntilMissing('.dropdown-menu')
+ ->waitUntilMissing('@session .meet-video:not(.publisher)');
+
+ // Expect a "end of session" dialog on the participant side
+ $guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Room closed')
+ ->assertSeeIn('@body', "The session has been closed by the room owner.")
+ ->assertMissing('@button-cancel')
+ ->assertSeeIn('@button-action', 'Close');
+ });
+ });
+ }
}
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -47,6 +47,8 @@
$browser->assertMissing('#footer-menu .navbar-nav');
}
+ // FIXME: Maybe it would be better to just display the usual 404 Not Found error page?
+
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
@@ -54,7 +56,7 @@
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-status-message', "The room does not exist.")
- ->assertMissing('@setup-button');
+ ->assertVisible('@setup-button[disabled]');
});
}
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -88,6 +88,8 @@
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing");
$response->assertStatus(404);
+ // TODO: Test accessing an existing room of deleted owner
+
// Non-owner, no session yet
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(423);
@@ -103,7 +105,7 @@
$session_id = $room->fresh()->session_id;
- $this->assertSame('PUBLISHER', $json['role']);
+ $this->assertSame(Room::ROLE_MODERATOR, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(is_string($session_id) && !empty($session_id));
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
@@ -117,7 +119,7 @@
$json = $response->json();
- $this->assertSame('PUBLISHER', $json['role']);
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
@@ -153,8 +155,129 @@
// Make sure the room owner can access the password protected room w/o password
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(200);
+ }
- // TODO: Test accessing an existing room of deleted owner
+ /**
+ * Test locked room and join requests
+ *
+ * @group openvidu
+ */
+ public function testJoinRequests(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = Room::where('name', 'john')->first();
+ $room->session_id = null;
+ $room->save();
+ $room->setSettings(['password' => null, 'locked' => 'true']);
+
+ $this->assignBetaEntitlement($john, 'meet');
+
+ // Create the session (also makes sure the owner can access a locked room)
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ // Non-owner, locked room, invalid/missing input
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
+ $response->assertStatus(426);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to join the session. Room locked.', $json['message']);
+ $this->assertTrue($json['config']['locked']);
+
+ // Non-owner, locked room, invalid requestId
+ $post = ['nickname' => 'name', 'requestId' => '-----'];
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(426);
+
+ // Non-owner, locked room, invalid requestId
+ $post = ['nickname' => 'name', 'picture' => '-----'];
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(426);
+
+ // Non-owner, locked room, valid input
+ $reqId = '12345678';
+ $post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234'];
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(427);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to join the session. Room locked.', $json['message']);
+ $this->assertTrue($json['config']['locked']);
+
+ // TODO: How do we assert that a signal has been sent to the owner?
+
+ // Test denying a request
+
+ // Unknown room
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/deny");
+ $response->assertStatus(404);
+
+ // Unknown request Id
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/deny");
+ $response->assertStatus(500);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to deny the join request.', $json['message']);
+
+ // Non-owner access forbidden
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
+ $response->assertStatus(403);
+
+ // Valid request
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/deny");
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+
+ // Non-owner, locked room, join request denied
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(427);
+
+ // Test accepting a request
+
+ // Unknown room
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/unknown/request/unknown/accept");
+ $response->assertStatus(404);
+
+ // Unknown request Id
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/unknown/accept");
+ $response->assertStatus(500);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to accept the join request.', $json['message']);
+
+ // Non-owner access forbidden
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
+ $response->assertStatus(403);
+
+ // Valid request
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/request/{$reqId}/accept");
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+
+ // Non-owner, locked room, join request accepted
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
+ $this->assertTrue(strpos($json['token'], 'wss://') === 0);
+
+ // TODO: Test a scenario where both password and lock are enabled
}
/**
@@ -177,7 +300,7 @@
$json = $response->json();
- $this->assertSame('PUBLISHER', $json['role']);
+ $this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($room->session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue(strpos($json['shareToken'], 'wss://') === 0);
@@ -238,6 +361,69 @@
}
/**
+ * Test dismissing a participant (closing a connection)
+ *
+ * @group openvidu
+ */
+ public function testDismissConnection(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = Room::where('name', 'john')->first();
+ $room->session_id = null;
+ $room->save();
+
+ $this->assignBetaEntitlement($john, 'meet');
+
+ // First we create the session
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ // And the other user connection
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $conn_id = $json['connectionId'];
+ $room->refresh();
+ $conn_data = $room->getOVConnection($conn_id);
+
+ $this->assertSame($conn_id, $conn_data['connectionId']);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}/dismiss");
+ $response->assertStatus(404);
+
+ // TODO: Test accessing an existing room of deleted owner
+
+ // Non-existing connection
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/123/dismiss");
+ $response->assertStatus(500);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame('Failed to dismiss the connection.', $json['message']);
+
+ // Non-owner access
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
+ $response->assertStatus(403);
+
+ // Expected success
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertNull($room->getOVConnection($conn_id));
+ }
+
+ /**
* Test configuring the room (session)
*
* @group openvidu
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 3:04 PM (17 h, 59 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18830092
Default Alt Text
D1912.1775315073.diff (61 KB)
Attached To
Mode
D1912: OpenVidu: Room lock
Attached
Detach File
Event Timeline