Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F120840152
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
151 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php
index aebd1310..aaedece2 100644
--- a/src/app/Http/Controllers/API/V4/OpenViduController.php
+++ b/src/app/Http/Controllers/API/V4/OpenViduController.php
@@ -1,489 +1,531 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class OpenViduController extends Controller
{
public const AUTH_HEADER = 'X-Meet-Auth-Token';
/**
* Accept 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'));
}
// 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']);
}
/**
* 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'));
}
// 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'),
]);
}
+ /**
+ * Create a connection for screen sharing.
+ *
+ * @param string $id Room identifier (name)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function createConnection($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'));
+ }
+
+ $connection = $this->getConnectionFromRequest();
+
+ if (
+ !$connection
+ || $connection->session_id != $room->session_id
+ || ($connection->role & Room::ROLE_PUBLISHER) == 0
+ ) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $room->getSessionToken(Room::ROLE_SCREEN);
+
+ return response()->json(['status' => 'success', 'token' => $response['token']]);
+ }
+
/**
* 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'));
}
// 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.
+ * Listing of rooms that belong to the authenticated 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'];
- }
-
// Get up-to-date connections metadata
$response['connections'] = $room->getSessionConnections();
$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':
// The 'owner' role is not assignable
if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
} elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) {
return $this->errorResponse(403);
}
// The room owner has always a 'moderator' role
if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
$value |= Room::ROLE_MODERATOR;
}
$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;
}
// Moderator's authentication via the extra request header
+ if (
+ ($connection = $this->getConnectionFromRequest())
+ && $connection->session_id === $room->session_id
+ && $connection->role & Room::ROLE_MODERATOR
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the connection object for the token in current request headers.
+ * It will also validate the token.
+ *
+ * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
+ */
+ protected function getConnectionFromRequest()
+ {
+ // Authenticate the user via the extra request header
if ($token = request()->header(self::AUTH_HEADER)) {
list($connId, ) = explode(':', base64_decode($token), 2);
if (
($connection = Connection::find($connId))
- && $connection->session_id === $room->session_id
&& $connection->metadata['authToken'] === $token
- && $connection->role & Room::ROLE_MODERATOR
) {
- return true;
+ return $connection;
}
}
- return false;
+ return null;
}
}
diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js
index a9e7b814..b758058e 100644
--- a/src/resources/js/meet/app.js
+++ b/src/resources/js/meet/app.js
@@ -1,1339 +1,1355 @@
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 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 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.setupStart = setupStart
this.setupStop = setupStop
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 (token, shareToken, nickname, role, connections,
* chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange,
* onSessionDataUpdate, onMediaSetup)
*/
function joinRoom(data) {
resize();
volumeMeterStop()
data.params = {
nickname: data.nickname, // user nickname
// avatar: undefined // avatar image
}
// Create a container for subscribers
if (!subscribersContainer) {
subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
}
sessionData = data
// Init a session
session = OV.initSession()
// Handle connection creation events
session.on('connectionCreated', event => {
// Ignore the current user connection
if (event.connection.role) {
return
}
// This is the first event executed when a user joins in.
// We'll create the video wrapper here, which can be re-used
// in 'streamCreated' event handler.
let metadata = connectionData(event.connection)
const connId = metadata.connectionId
// The connection metadata here is the initial metadata set on
// connection initialization. There's no way to update it via OpenVidu API.
// So, we merge the initial connection metadata with up-to-dated one that
// we got from our database.
if (sessionData.connections && connId in sessionData.connections) {
Object.assign(metadata, sessionData.connections[connId])
delete sessionData.connections[connId]
}
metadata.element = participantCreate(metadata)
connections[connId] = metadata
// Send the current user status to the connecting user
// otherwise e.g. nickname might be not up to date
signalUserUpdate(event.connection)
})
session.on('connectionDestroyed', event => {
- let conn = connections[event.connection.connectionId]
+ let connectionId = event.connection.connectionId
+ let conn = connections[connectionId]
+
if (conn) {
$(conn.element).remove()
- delete connections[event.connection.connectionId]
+ delete connections[connectionId]
}
+
resize()
})
// On every new Stream received...
session.on('streamCreated', event => {
let connectionId = event.stream.connection.connectionId
let metadata = connections[connectionId]
let props = {
// Prepend the video element so it is always before the watermark element
insertMode: 'PREPEND'
}
// Subscribe to the Stream to receive it
let subscriber = session.subscribe(event.stream, metadata.element, props);
subscriber.on('videoElementCreated', event => {
$(event.element).prop({
tabindex: -1
})
resize()
})
metadata.audioActive = event.stream.audioActive
metadata.videoActive = event.stream.videoActive
// Update the wrapper controls/status
participantUpdate(metadata.element, metadata)
})
// Stream properties changes e.g. audio/video muted/unmuted
session.on('streamPropertyChanged', event => {
let connectionId = event.stream.connection.connectionId
let metadata = connections[connectionId]
if (session.connection.connectionId == connectionId) {
metadata = sessionData
}
if (metadata) {
metadata[event.changedProperty] = event.newValue
participantUpdate(metadata.element, metadata)
}
})
// Handle session disconnection events
session.on('sessionDisconnected', event => {
if (data.onDestroy) {
data.onDestroy(event)
}
resize()
})
// Handle signals from all participants
session.on('signal', signalEventHandler)
// Connect with the token
session.connect(data.token, data.params)
.then(() => {
let params = {
connectionId: session.connection.connectionId,
role: data.role,
audioActive,
videoActive
}
params = Object.assign({}, data.params, params)
publisher.on('videoElementCreated', event => {
$(event.element).prop({
muted: true, // Mute local video to avoid feedback
disablePictureInPicture: true, // this does not work in Firefox
tabindex: -1
})
resize()
})
let wrapper = participantCreate(params)
if (data.role & Roles.PUBLISHER) {
publisher.createVideoElement(wrapper, 'PREPEND')
session.publish(publisher)
}
sessionData.element = wrapper
})
.catch(error => {
console.error('There was an error connecting to the session: ', error.message);
})
// Prepare the chat
setupChat()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom() {
if (publisher) {
volumeMeterStop()
// FIXME: We have to unpublish streams only if there's no session yet
if (!session && audioActive) {
publisher.publishAudio(false)
}
if (!session && videoActive) {
publisher.publishVideo(false)
}
publisher = null
}
if (session) {
session.disconnect();
session = null
}
if (screenSession) {
screenSession.disconnect();
screenSession = null
}
}
/**
* Sets the audio and video devices for the session.
* This will ask user for permission to access media devices.
*
* @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
*/
function setupStart(props) {
// Note: After changing media permissions in Chrome/Firefox a page refresh is required.
// That means that in a scenario where you first blocked access to media devices
// and then allowed it we can't ask for devices list again and expect a different
// result than before.
// That's why we do not bother, and return ealy when we open the media setup dialog.
if (publisher) {
volumeMeterStart()
return
}
publisher = OV.initPublisher(undefined, publisherDefaults)
publisher.once('accessDenied', error => {
props.onError(error)
})
publisher.once('accessAllowed', async () => {
let mediaStream = publisher.stream.getMediaStream()
let videoStream = mediaStream.getVideoTracks()[0]
let audioStream = mediaStream.getAudioTracks()[0]
audioActive = !!audioStream
videoActive = !!videoStream
volumeElement = props.volumeElement
publisher.addVideoElement(props.videoElement)
volumeMeterStart()
const devices = await OV.getDevices()
devices.forEach(device => {
// device's props: deviceId, kind, label
if (device.kind == 'videoinput') {
cameras.push(device)
if (videoStream && videoStream.label == device.label) {
videoSource = device.deviceId
}
} else if (device.kind == 'audioinput') {
microphones.push(device)
if (audioStream && audioStream.label == device.label) {
audioSource = device.deviceId
}
}
})
props.onSuccess({
microphones,
cameras,
audioSource,
videoSource,
audioActive,
videoActive
})
})
}
/**
* Stop the setup "process", cleanup after it.
*/
function setupStop() {
volumeMeterStop()
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
if (!deviceId) {
publisher.publishAudio(false)
volumeMeterStop()
audioActive = false
} else if (deviceId == audioSource) {
publisher.publishAudio(true)
volumeMeterStart()
audioActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoActive,
audioSource: deviceId,
videoSource: videoSource
})
volumeMeterStop()
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
mediaStream.getAudioTracks().forEach(track => {
track.stop()
mediaStream.removeTrack(track)
})
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
await replaceTrack(newMediaStream.getAudioTracks()[0])
volumeMeterStart()
audioActive = true
audioSource = deviceId
})
}
return audioActive
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
if (!deviceId) {
publisher.publishVideo(false)
videoActive = false
} else if (deviceId == videoSource) {
publisher.publishVideo(true)
videoActive = true
} else {
const mediaStream = publisher.stream.mediaStream
const properties = Object.assign({}, publisherDefaults, {
publishAudio: audioActive,
publishVideo: true,
audioSource: audioSource,
videoSource: deviceId
})
volumeMeterStop()
// Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
mediaStream.getVideoTracks().forEach(track => {
track.stop()
mediaStream.removeTrack(track)
})
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
await replaceTrack(newMediaStream.getVideoTracks()[0])
volumeMeterStart()
videoActive = true
videoSource = deviceId
})
}
return videoActive
}
/**
* A way to switch tracks in a stream.
* Note: This is close to what publisher.replaceTrack() does but it does not
* require the session.
* Note: The old track needs to be removed before OV.getUserMedia() call,
* otherwise we get "Concurrent mic process limit" error.
*/
function replaceTrack(track) {
const stream = publisher.stream
const replaceMediaStreamTrack = () => {
stream.mediaStream.addTrack(track);
if (session) {
session.sendVideoData(publisher.stream.streamManager, 5, true, 5);
}
}
return new Promise((resolve, reject) => {
if (stream.isLocalStreamPublished) {
// Only if the Publisher has been published it is necessary to call the native
// Web API RTCRtpSender.replaceTrack()
const senders = stream.getRTCPeerConnection().getSenders()
let sender
if (track.kind === 'video') {
sender = senders.find(s => !!s.track && s.track.kind === 'video')
} else {
sender = senders.find(s => !!s.track && s.track.kind === 'audio')
}
if (!sender) return
sender.replaceTrack(track).then(() => {
replaceMediaStreamTrack()
resolve()
}).catch(error => {
reject(error)
})
} else {
// Publisher not published. Simply modify local MediaStream tracks
replaceMediaStreamTrack()
resolve()
}
})
}
/**
* Setup the chat UI
*/
function setupChat() {
// The UI elements are created in the vue template
// Here we add a logic for how they work
const textarea = $(sessionData.chatElement).find('textarea')
const button = $(sessionData.menuElement).find('.link-chat')
textarea.on('keydown', e => {
if (e.keyCode == 13 && !e.shiftKey) {
if (textarea.val().length) {
signalChat(textarea.val())
textarea.val('')
}
return false
}
})
// Add an element for the count of unread messages on the chat button
button.append('<span class="badge badge-dark blinker">')
.on('click', () => {
button.find('.badge').text('')
chatCount = 0
})
}
/**
* Signal events handler
*/
function signalEventHandler(signal) {
let conn, data
let connId = signal.from ? signal.from.connectionId : null
switch (signal.type) {
case 'signal:userChanged':
// TODO: Use 'signal:connectionUpdate' for nickname updates?
if (conn = connections[connId]) {
data = JSON.parse(signal.data)
conn.nickname = data.nickname
participantUpdate(conn.element, conn)
nicknameUpdate(data.nickname, connId)
}
break
case 'signal:chat':
data = JSON.parse(signal.data)
data.id = connId
pushChatMessage(data)
break
case 'signal:joinRequest':
// accept requests from the server only
if (!connId && sessionData.onJoinRequest) {
sessionData.onJoinRequest(JSON.parse(signal.data))
}
break
case 'signal:connectionUpdate':
// accept requests from the server only
if (!connId) {
data = JSON.parse(signal.data)
connectionUpdate(data)
}
break
}
}
/**
* Send the chat message to other participants
*
* @param message Message string
*/
function signalChat(message) {
let data = {
nickname: sessionData.params.nickname,
message
}
session.signal({
data: JSON.stringify(data),
type: 'chat'
})
}
/**
* Add a message to the chat
*
* @param data Object with a message, nickname, id (of the connection, empty for self)
*/
function pushChatMessage(data) {
let message = $('<span>').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = anchorme({
input: message,
options: {
attributes: {
target: "_blank"
},
// any link above 20 characters will be truncated
// to 20 characters and ellipses at the end
truncate: 20,
// characters will be taken out of the middle
middleTruncation: true
}
// TODO: anchorme is extensible, we could support
// github/phabricator's markup e.g. backticks for code samples
})
message = message.replace(/\r?\n/, '<br>')
// Display the message
let isSelf = data.id == session.connectionId
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').html(message)
message.find('a').attr('rel', 'noreferrer')
if (box.length && box.data('id') == data.id) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('<div class="message">').data('id', data.id)
.append($('<div class="nickname">').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!isSelf) {
chatCount++
}
} else {
chatCount = 0
}
$(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
}
/**
* Send the user properties update signal to other participants
*
* @param connection Optional connection to which the signal will be sent
* If not specified the signal is sent to all participants
*/
function signalUserUpdate(connection) {
let data = {
nickname: sessionData.params.nickname
}
session.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
// The same nickname for screen sharing session
if (screenSession) {
screenSession.signal({
data: JSON.stringify(data),
type: 'userChanged',
to: connection ? [connection] : undefined
})
}
}
/**
* Mute/Unmute audio for current session publisher
*/
function switchAudio() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (microphones.length) {
try {
publisher.publishAudio(!audioActive)
audioActive = !audioActive
} catch (e) {
console.error(e)
}
}
return audioActive
}
/**
* Mute/Unmute video for current session publisher
*/
function switchVideo() {
// TODO: If user has no devices or denied access to them in the setup,
// the button will just not work. Find a way to make it working
// after user unlocks his devices. For now he has to refresh
// the page and join the room again.
if (cameras.length) {
try {
publisher.publishVideo(!videoActive)
videoActive = !videoActive
} catch (e) {
console.error(e)
}
}
return videoActive
}
/**
* Switch on/off screen sharing
*/
function switchScreen(callback) {
if (screenPublisher) {
+ // Note: This is what the original openvidu-call app does.
+ // It is probably better for performance reasons to close the connection,
+ // than to use unpublish() and keep the connection open.
screenSession.disconnect()
screenSession = null
screenPublisher = null
if (callback) {
- // Note: Disconnecting invalidates the token. The callback should request
- // a new token for the next screen sharing session.
+ // Note: Disconnecting invalidates the token, we have to inform the vue component
+ // to update UI state (and be prepared to request a new token).
callback(false)
}
return
}
screenConnect(callback)
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!OV.checkScreenSharingCapabilities();
}
/**
* Update participant connection state
*/
function connectionUpdate(data) {
let conn = connections[data.connectionId]
// It's me
if (session.connection.connectionId == data.connectionId) {
const rolePublisher = data.role && data.role & Roles.PUBLISHER
const roleModerator = data.role && data.role & Roles.MODERATOR
const isPublisher = sessionData.role & Roles.PUBLISHER
const isModerator = sessionData.role & Roles.MODERATOR
// Inform the vue component, so it can update some UI controls
let update = () => {
if (sessionData.onSessionDataUpdate) {
sessionData.onSessionDataUpdate(data)
}
}
// demoted to a subscriber
if ('role' in data && isPublisher && !rolePublisher) {
session.unpublish(publisher)
// FIXME: There's a reference in OpenVidu to a video element that should not
// exist anymore. It causes issues when we try to do publish/unpublish
// sequence multiple times in a row. So, we're clearing the reference here.
let videos = publisher.stream.streamManager.videos
publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null)
}
// merge the changed data into internal session metadata object
Object.keys(data).forEach(key => { sessionData[key] = data[key] })
// update the participant element
sessionData.element = participantUpdate(sessionData.element, sessionData)
// promoted/demoted to/from a moderator
if ('role' in data) {
if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) {
// Update all participants, to enable/disable the popup menu
Object.keys(connections).forEach(key => {
const conn = connections[key]
participantUpdate(conn.element, conn)
})
}
}
// promoted to a publisher
if ('role' in data && !isPublisher && rolePublisher) {
publisher.createVideoElement(sessionData.element, 'PREPEND')
session.publish(publisher).then(() => {
data.audioActive = publisher.stream.audioActive
data.videoActive = publisher.stream.videoActive
update()
})
// TODO: Here the user is asked for media permissions again
// should we rather start the stream without asking the user?
// Or maybe we want to display the media setup/preview form?
// Need to find a way to do this.
} else {
// Inform the vue component, so it can update some UI controls
update()
}
} else if (conn) {
// merge the changed data into internal session metadata object
Object.keys(data).forEach(key => { conn[key] = data[key] })
conn.element = participantUpdate(conn.element, conn)
}
}
/**
* Update nickname in chat
*
* @param nickname Nickname
* @param connectionId Connection identifier of the user
*/
function nicknameUpdate(nickname, connectionId) {
if (connectionId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == connectionId) {
elem.find('.nickname').text(nickname || '')
}
})
}
}
/**
* Create a participant element in the matrix. Depending on the connection role
* parameter it will be a video element wrapper inside the matrix or a simple
* tag-like element on the subscribers list.
*
* @param params Connection metadata/params
*
* @return The element
*/
function participantCreate(params) {
let element
params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId
if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) {
element = publisherCreate(params)
} else {
element = subscriberCreate(params)
}
setTimeout(resize, 50);
return element
}
/**
* Create a <video> element wrapper with controls
*
* @param params Connection metadata/params
*/
function publisherCreate(params) {
// Create the element
let wrapper = $(
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ '<button type="button" class="btn btn-link link-setup hidden" title="Media setup">' + svgIcon('cog') + '</button>'
+ '<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>'
+ '</div>'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (params.isSelf) {
if (sessionData.onMediaSetup) {
wrapper.find('.link-setup').removeClass('hidden')
.click(() => sessionData.onMediaSetup())
}
} else {
// Enable audio mute button
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')
})
}
participantUpdate(wrapper, params)
// Fullscreen control
if (document.fullscreenEnabled) {
wrapper.find('.link-fullscreen.closed').removeClass('hidden')
.on('click', () => {
wrapper.get(0).requestFullscreen()
})
wrapper.find('.link-fullscreen.open')
.on('click', () => {
document.exitFullscreen()
})
wrapper.on('fullscreenchange', () => {
// const enabled = document.fullscreenElement
wrapper.find('.link-fullscreen.closed').toggleClass('hidden')
wrapper.find('.link-fullscreen.open').toggleClass('hidden')
wrapper.toggleClass('fullscreen')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.connectionId).remove()
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](container)
.attr('id', 'publisher-' + params.connectionId)
.get(0)
}
/**
* Update the <video> wrapper controls
*
* @param wrapper The wrapper element
* @param params Connection metadata/params
*/
function participantUpdate(wrapper, params) {
const element = $(wrapper)
const isModerator = sessionData.role & Roles.MODERATOR
const isSelf = session.connection.connectionId == params.connectionId
const rolePublisher = params.role & Roles.PUBLISHER
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
// Handle publisher-to-subscriber and subscriber-to-publisher change
if (!roleScreen) {
const isPublisher = element.is('.meet-video')
if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) {
element.remove()
return participantCreate(params)
}
}
if ('audioActive' in params) {
element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden')
}
if ('videoActive' in params) {
element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden')
}
if ('nickname' in params) {
element.find('.meet-nickname > .content').text(params.nickname)
}
if (isSelf) {
element.addClass('self')
}
if (isModerator) {
element.addClass('moderated')
}
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
const withMenu = isSelf || (isModerator && !roleOwner)
let elements = {
'.dropdown-menu': withMenu,
'.permissions': withPerm,
'svg.moderator': roleModerator,
'svg.user': !roleModerator
}
Object.keys(elements).forEach(key => {
element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
})
element.find('.action-role-publisher input').prop('checked', rolePublisher)
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
return wrapper
}
/**
* Create a tag-like element for a subscriber participant
*
* @param params Connection metadata/params
*/
function subscriberCreate(params) {
// Create the element
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
participantUpdate(wrapper, params)
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.connectionId)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @param object params Connection metadata/params
*/
function nicknameWidget(params) {
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ '<div class="dropdown-divider permissions"></div>'
+ '<div class="permissions">'
+ '<h6 class="dropdown-header">Permissions</h6>'
+ '<label class="dropdown-item action-role-publisher custom-control custom-switch">'
+ '<input type="checkbox" class="custom-control-input">'
+ ' <span class="custom-control-label">Audio & Video publishing</span>'
+ '</label>'
+ '<label class="dropdown-item action-role-moderator custom-control custom-switch">'
+ '<input type="checkbox" class="custom-control-input">'
+ ' <span class="custom-control-label">Moderation</span>'
+ '</label>'
+ '</div>'
+ '</div>'
+ '</div>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: 'Options', 'data-toggle': 'dropdown'})
.dropdown({boundary: container})
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
editable.contentEditable = false
sessionData.params.nickname = editable.innerText
signalUserUpdate()
nicknameUpdate(editable.innerText, session.connection.connectionId)
}
element.find('.action-nickname').on('click', editableEnable)
element.find('.action-dismiss').remove()
$(editable).on('blur', editableUpdate)
.on('keydown', e => {
// Enter or Esc
if (e.keyCode == 13 || e.keyCode == 27) {
editableUpdate()
return false
}
})
} else {
element.find('.action-nickname').remove()
element.find('.action-dismiss').on('click', e => {
if (sessionData.onDismiss) {
sessionData.onDismiss(params.connectionId)
}
})
}
let connectionRole = () => {
if (params.isSelf) {
return sessionData.role
}
if (params.connectionId in connections) {
return connections[params.connectionId].role
}
return 0
}
// Don't close the menu on permission change
element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
if (sessionData.onConnectionChange) {
element.find('.action-role-publisher input').on('change', e => {
const enabled = e.target.checked
let role = connectionRole()
if (enabled) {
role |= Roles.PUBLISHER
} else {
role |= Roles.SUBSCRIBER
if (role & Roles.PUBLISHER) {
role ^= Roles.PUBLISHER
}
}
sessionData.onConnectionChange(params.connectionId, { role })
})
element.find('.action-role-moderator input').on('change', e => {
const enabled = e.target.checked
let role = connectionRole()
if (enabled) {
role |= Roles.MODERATOR
} else if (role & Roles.MODERATOR) {
role ^= Roles.MODERATOR
}
sessionData.onConnectionChange(params.connectionId, { role })
})
}
return element.get(0)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
containerWidth = container.offsetWidth
containerHeight = container.offsetHeight
if (subscribersContainer) {
containerHeight -= subscribersContainer.offsetHeight
}
updateLayout()
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let numOfVideos = $(container).find('.meet-video').length
if (!numOfVideos) {
return
}
let css, rows, cols, height
const factor = containerWidth / containerHeight
if (factor >= 16/9) {
if (numOfVideos <= 3) {
rows = 1
} else if (numOfVideos <= 8) {
rows = 2
} else if (numOfVideos <= 15) {
rows = 3
} else if (numOfVideos <= 20) {
rows = 4
} else {
rows = 5
}
cols = Math.ceil(numOfVideos / rows)
} else {
if (numOfVideos == 1) {
cols = 1
} else if (numOfVideos <= 4) {
cols = 2
} else if (numOfVideos <= 9) {
cols = 3
} else if (numOfVideos <= 16) {
cols = 4
} else if (numOfVideos <= 25) {
cols = 5
} else {
cols = 6
}
rows = Math.ceil(numOfVideos / cols)
if (rows < cols && containerWidth < containerHeight) {
cols = rows
rows = Math.ceil(numOfVideos / cols)
}
}
// console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows);
height = containerHeight / rows
css = {
width: (100 / cols) + '%',
// Height must be in pixels to make object-fit:cover working
height: height + 'px'
}
// Update the matrix
$(container).find('.meet-video').css(css)
/*
.each((idx, elem) => {
let video = $(elem).children('video')[0]
if (video && video.videoWidth && video.videoHeight && video.videoWidth > video.videoHeight) {
// Set max-width to keep the original aspect ratio in cases
// when there's enough room to display the element
let maxWidth = height * video.videoWidth / video.videoHeight
$(elem).css('max-width', maxWidth)
}
})
*/
}
/**
* Initialize screen sharing session/publisher
*/
function screenConnect(callback) {
if (!sessionData.shareToken) {
return false
}
let gotSession = !!screenSession
+ if (!screenOV) {
+ screenOV = new OpenVidu()
+ }
+
// Init screen sharing session
if (!gotSession) {
screenSession = screenOV.initSession();
}
let successFunc = function() {
screenSession.publish(screenPublisher)
+
+ screenSession.on('sessionDisconnected', event => {
+ callback(false)
+ screenSession = null
+ screenPublisher = null
+ })
+
if (callback) {
callback(true)
}
}
let errorFunc = function() {
screenPublisher = null
if (callback) {
- callback(false)
+ callback(false, true)
}
}
// Init the publisher
let params = {
videoSource: 'screen',
publishAudio: false
}
screenPublisher = screenOV.initPublisher(null, params)
screenPublisher.once('accessAllowed', (event) => {
if (gotSession) {
successFunc()
} else {
screenSession.connect(sessionData.shareToken, sessionData.params)
.then(() => {
successFunc()
})
.catch(error => {
console.error('There was an error connecting to the session:', error.code, error.message);
errorFunc()
})
}
})
screenPublisher.once('accessDenied', () => {
console.info('ScreenShare: Access Denied')
errorFunc()
})
}
/**
* Create an svg element (string) for a FontAwesome icon
*
* @todo Find if there's a "official" way to do this
*/
function svgIcon(name, type, className) {
// Note: the library will contain definitions for all icons registered elswhere
const icon = library.definitions[type || 'fas'][name]
let attrs = {
'class': 'svg-inline--fa',
'aria-hidden': true,
focusable: false,
role: 'img',
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `0 0 ${icon[0]} ${icon[1]}`
}
if (className) {
attrs['class'] += ' ' + className
}
return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
.attr(attrs)
.get(0).outerHTML
}
/**
* A way to update some session data, after you joined the room
*
* @param data Same input as for joinRoom(), but for now it supports only shareToken
*/
function updateSession(data) {
sessionData.shareToken = data.shareToken
}
/**
* A handler for volume level change events
*/
function volumeChangeHandler(event) {
let value = 100 + Math.min(0, Math.max(-100, event.value.newValue))
let color = 'lime'
const bar = volumeElement.firstChild
if (value >= 70) {
color = '#ff3300'
} else if (value >= 50) {
color = '#ff9933'
}
bar.style.height = value + '%'
bar.style.background = color
}
/**
* Start the volume meter
*/
function volumeMeterStart() {
if (publisher && volumeElement) {
publisher.on('streamAudioVolumeChange', volumeChangeHandler)
}
}
/**
* Stop the volume meter
*/
function volumeMeterStop() {
if (publisher && volumeElement) {
publisher.off('streamAudioVolumeChange')
volumeElement.firstChild.style.height = 0
}
}
function connectionData(connection) {
// Note: we're sending a json from two sources (server-side when
// creating a token/connection, and client-side when joining the session)
// OpenVidu is unable to merge these two objects into one, for it it is only
// two strings, so it puts a "%/%" separator in between, we'll replace it with comma
// to get one parseable json object
let data = JSON.parse(connection.data.replace('}%/%{', ','))
data.connectionId = connection.connectionId
return data
}
}
export { Meet, Roles }
diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue
index 878027e9..be5db08b 100644
--- a/src/resources/vue/Meet/Room.vue
+++ b/src/resources/vue/Meet/Room.vue
@@ -1,676 +1,679 @@
<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" :disabled="!isPublisher()" title="Mute audio">
<svg-icon icon="microphone"></svg-icon>
</button>
<button class="btn btn-link link-video" @click="switchVideo" :disabled="!isPublisher()" title="Mute video">
<svg-icon icon="video"></svg-icon>
</button>
<button class="btn btn-link link-screen text-danger" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" 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="isRoomOwner()" @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="media-setup-form row" @submit.prevent="joinSession">
<div class="media-setup-preview 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 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="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="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 type="submit" id="join-button"
:class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')"
>
<span v-if="isRoomReady()">JOIN NOW</span>
<span v-else-if="roomState == 323">I'm the owner</span>
<span v-else>JOIN</span>
</button>
</div>
</div>
<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>
<div id="media-setup-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">Media setup</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form class="media-setup-form">
<div class="media-setup-preview"></div>
<div class="input-group mt-2">
<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="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>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary 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, Roles } from '../../js/meet/app.js'
import StatusMessage from '../Widgets/StatusMessage'
import LogonForm from '../Login'
import SessionSecurityOptions from './SessionSecurityOptions'
// Register additional icons
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignLeft,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faMicrophone,
faPowerOff,
faUser,
faShieldAlt,
faVideo,
faVolumeMute
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faAlignLeft,
faCog,
faCompress,
faCrown,
faDesktop,
faExpand,
faMicrophone,
faPowerOff,
faUser,
faShieldAlt,
faVideo,
faVolumeMute
)
let roomRequest
const authHeader = 'X-Meet-Auth-Token'
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.',
323: 'The room is closed. Please, wait for the owner to start the session.',
324: 'The room is closed. It will be open for others after you join.',
325: 'The room is ready. Please, provide a valid password.',
326: 'The room is locked. Please, enter your name and try again.',
327: 'Waiting for permission to join the room.',
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(roomRequest)
if (this.meet) {
this.meet.leaveRoom()
}
delete axios.defaults.headers.common[authHeader]
},
methods: {
authSuccess() {
// The user authentication succeeded, we still don't know it's really the room owner
this.initSession()
$('#meet-setup').removeClass('hidden')
$('#meet-auth').addClass('hidden')
},
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) {
const button = $('#join-button').prop('disabled', true)
this.post = {
password: this.password,
nickname: this.nickname,
screenShare: this.canShareScreen ? 1 : 0,
init: init ? 1 : 0,
picture: init ? this.makePicture() : '',
requestId: this.requestId(),
canPublish: !!this.camera || !!this.microphone
}
$('#setup-password,#setup-nickname').removeClass('is-invalid')
axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true })
.then(response => {
button.prop('disabled', false)
// We already have token, the response is redundant
if (this.roomState == 'ready' && this.session.token) {
return
}
this.roomState = 'ready'
this.session = response.data
if (init) {
this.joinSession()
}
if (this.session.authToken) {
axios.defaults.headers.common[authHeader] = this.session.authToken
}
})
.catch(error => {
if (!error.response) {
console.error(error)
return
}
const data = error.response.data || {}
if (data.code) {
this.roomState = data.code
} else {
this.roomState = error.response.status
}
button.prop('disabled', this.roomState == 'init' || this.roomState == 327 || this.roomState == 404)
if (data.config) {
this.session.config = data.config
}
switch (this.roomState) {
case 323:
// Waiting for the owner to open the room...
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession() }, 10000)
break;
case 324:
// Room is ready for the owner, but the 'init' was not requested yet
clearTimeout(roomRequest)
break;
case 325:
// Missing/invalid password
if (init) {
$('#setup-password').addClass('is-invalid').focus()
}
break;
case 326:
// Locked room prerequisites error
if (init && !$('#setup-nickname').val()) {
$('#setup-nickname').addClass('is-invalid').focus()
}
break;
case 327:
// Waiting for the owner's approval to join
// Update room state every 10 seconds
roomRequest = setTimeout(() => { this.initSession(true) }, 10000)
break;
}
})
if (document.fullscreenEnabled) {
$('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden')
}
},
isModerator() {
return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0)
},
isPublisher() {
return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0
},
isRoomOwner() {
return !!this.session.role && (this.session.role & Roles.OWNER) > 0
},
isRoomReady() {
return ['ready', 322, 324, 325, 326, 327].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() {
// The form can be submitted not only via the submit button,
// make sure the submit is allowed
if ($('#meet-setup [type=submit]').prop('disabled')) {
return;
}
if (this.roomState == 323) {
$('#meet-setup').addClass('hidden')
$('#meet-auth').removeClass('hidden')
return
}
if (this.roomState != 'ready') {
this.initSession(true)
return
}
clearTimeout(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: Display different message for each reason: forceDisconnectByUser,
// forceDisconnectByServer, sessionClosedByServer?
if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.isRoomOwner()) {
$('#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).
this.$router.push({ name: 'dashboard' })
}).modal()
}
}
this.session.onDismiss = connId => { this.dismissParticipant(connId) }
this.session.onSessionDataUpdate = data => { this.updateSession(data) }
this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) }
this.session.onJoinRequest = data => { this.joinRequest(data) }
this.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
},
logout() {
const logout = () => {
this.meet.leaveRoom()
this.meet = null
this.$router.push({ name: 'dashboard' })
}
if (this.isRoomOwner()) {
axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout)
} else {
logout()
}
},
makePicture() {
const video = $("#meet-setup 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()
},
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)
}
},
setupMedia() {
let dialog = $('#media-setup-dialog')
if (!dialog.find('video').length) {
$('#meet-setup').find('video,div.volume').appendTo(dialog.find('.media-setup-preview'))
}
dialog.on('show.bs.modal', () => { this.meet.setupStart() })
.on('hide.bs.modal', () => { this.meet.setupStop() })
.modal()
},
setupSession() {
this.meet.setupStart({
videoElement: $('#meet-setup video')[0],
volumeElement: $('#meet-setup .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) {
- // TODO: This might need to be a different route. E.g. the room password might have
- // changed since user joined the session
- // Also because it creates a redundant connection (token)
- 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.shareToken
- this.meet.updateSession(this.session)
- })
- }
- })
+ const switchScreenAction = () => {
+ this.meet.switchScreen((enabled, error) => {
+ this.setMenuItem('screen', enabled)
+ if (!enabled && !error) {
+ // Closing a screen sharing connection invalidates the token
+ delete this.session.shareToken
+ }
+ })
+ }
+
+ if (this.session.shareToken || !$('#meet-session-menu').find('.link-screen').is('.text-danger')) {
+ switchScreenAction()
+ } else {
+ axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
+ .then(response => {
+ this.session.shareToken = response.data.token
+ this.meet.updateSession(this.session)
+ switchScreenAction()
+ })
+ }
},
updateParticipant(connId, params) {
if (this.isModerator()) {
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
updateSession(data) {
let params = {}
if ('role' in data) {
params.role = data.role
}
// merge new params into the object
this.session = Object.assign({}, this.session, params)
// update some buttons state e.g. when switching from publisher to subscriber
if (!this.isPublisher()) {
this.setMenuItem('audio', false)
this.setMenuItem('video', false)
} else {
if ('videoActive' in data) {
this.setMenuItem('video', data.videoActive)
}
if ('audioActive' in data) {
this.setMenuItem('audio', data.audioActive)
}
}
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index d3909efa..86c4eaeb 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,161 +1,162 @@
<?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');
// 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::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
// 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::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
+ Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
// 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::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
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/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php
index 6dcecb73..0aa6bbae 100644
--- a/src/tests/Browser/Meet/RoomControlsTest.php
+++ b/src/tests/Browser/Meet/RoomControlsTest.php
@@ -1,396 +1,397 @@
<?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->clearMeetEntitlements();
}
public function tearDown(): void
{
$this->clearMeetEntitlements();
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->assignMeetEntitlement('john@kolab.org');
$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->assignMeetEntitlement('john@kolab.org');
$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')
->keys('@setup-nickname-input', '{enter}') // Test form submit with Enter key
->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', '')
->clickWhenEnabled('@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_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.self', function (Browser $browser) {
$browser->waitFor('video')
->assertAudioMuted('video', true)
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Assert current UI state
$guest->assertToolbar([
'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
'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:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test nickname change propagation
$guest->setNickname('div.meet-video.self', 'guest');
$owner->waitFor('div.meet-video:not(.self) .meet-nickname')
->assertSeeIn('div.meet-video:not(.self) .meet-nickname', 'guest');
// Test muting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .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(.self) .status .status-audio');
// Test unmuting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio');
// Test muting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .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(.self) .status .status-video');
// Test unmuting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-video');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-video');
// Test muting other user
$guest->with('div.meet-video:not(.self)', 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
*/
public function testChat(): 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->assignMeetEntitlement('john@kolab.org');
$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')
->clickWhenEnabled('@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', '')
->clickWhenEnabled('@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', '')
+ ->waitFor('@chat-list .message')
->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.self', '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
{
// Make sure there's no session yet
$room = Room::where('name', 'john')->first();
if ($room->session_id) {
$room->session_id = null;
$room->save();
}
$this->assignMeetEntitlement('john@kolab.org');
$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')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test screen sharing
$owner->assertToolbarButtonState('screen', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertElementsCount('@session div.meet-video', 1)
->click('@menu button.link-screen')
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1)
->assertToolbarButtonState('screen', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
$guest
->whenAvailable('div.meet-video:nth-child(3)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1);
});
}
}
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
index 3b21fda1..9971c8a7 100644
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -1,692 +1,756 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\OpenViduController;
use App\OpenVidu\Connection;
use App\OpenVidu\Room;
use Tests\TestCase;
class OpenViduTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
$room->setSettings(['password' => null, 'locked' => null]);
}
public function tearDown(): void
{
$this->clearMeetEntitlements();
$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->assignMeetEntitlement($john);
// Unauth access, no session yet
$response = $this->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Non-existing room name
$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(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Room owner, no session yet
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(324, $json['code']);
$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(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $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, no 'init' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(322, $json['code']);
$this->assertTrue(empty($json['token']));
- $this->assertTrue(empty($json['shareToken']));
// Non-owner, now the session exists, with 'init', but no 'canPublish' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
- $this->assertTrue(empty($json['shareToken']));
// Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
$post = ['canPublish' => true, 'init' => 1];
$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->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(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(325, $json['code']);
$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(422);
$json = $response->json();
$this->assertSame(325, $json['code']);
// Non-owner, password protected room, valid password provided
// TODO: Test without init=1
$post = ['password' => 'pass', 'init' => 'init'];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$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
// TODO: Test without init=1
$post = ['init' => 'init'];
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
}
/**
* 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->assignMeetEntitlement($john);
// 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(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(326, $json['code']);
$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' => '-----', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// Non-owner, locked room, invalid requestId
$post = ['nickname' => 'name', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// 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(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(327, $json['code']);
$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(422);
$json = $response->json();
$this->assertSame(327, $json['code']);
// 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
$post['init'] = 1;
$post['canPublish'] = true;
$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
// TODO: Test accepting/denying as a non-owner moderator
}
/**
* Test joining the room
*
* @group openvidu
* @depends testJoinRoom
*/
public function testJoinRoomGuest(): void
{
$this->assignMeetEntitlement('john@kolab.org');
// 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
$post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
$response = $this->post("api/v4/openvidu/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$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);
- $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 creating an extra connection for screen sharing
+ *
+ * @group openvidu
+ */
+ public function testCreateConnection(): 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->assignMeetEntitlement($john);
+
+ // First we create the session
+ $post = ['init' => 1, 'canPublish' => 1];
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $owner_auth_token = $json['authToken'];
+
+ // 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'];
+ $auth_token = $json['authToken'];
+
+ // Non-existing room name
+ $response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []);
+ $response->assertStatus(404);
+
+ // No connection token provided
+ $response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+ $response->assertStatus(403);
+
+ // Invalid token
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => '123'])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(403);
+
+ // Subscriber can't get the screen-sharing connection
+ // Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user
+ // throughout the test
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(403);
+
+ // Publisher can get the connection
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertTrue(strpos($json['token'], 'wss://') === 0);
+ $this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false);
+ }
+
/**
* 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->assignMeetEntitlement($john);
// 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(404);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $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 acting as a moderator
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$conn_id = $json['connectionId'];
// Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
// throughout the test
$response = $this->actingAs($jack)
->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)])
->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
$response->assertStatus(200);
}
/**
* 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'));
}
/**
* Test updating a participant (connection)
*
* @group openvidu
*/
public function testUpdateConnection(): 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->assignMeetEntitlement($john);
// First we create the session
$response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$owner_conn_id = $json['connectionId'];
// 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'];
$auth_token = $json['authToken'];
$room->refresh();
$conn_data = $room->getOVConnection($conn_id);
$this->assertSame($conn_id, $conn_data['connectionId']);
// Non-existing room name
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []);
$response->assertStatus(404);
// Non-existing connection
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []);
$response->assertStatus(404);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $json['message']);
// Non-owner access
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []);
$response->assertStatus(403);
// Expected success
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
$response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame($post['role'], Connection::find($conn_id)->role);
// Access as moderator
// Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
// throughout the test
$token = $this->getModeratorToken($room);
$post = ['role' => Room::ROLE_PUBLISHER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame($post['role'], Connection::find($conn_id)->role);
// Assert that it's not possible to add/remove the 'owner' role
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
$post = ['role' => Room::ROLE_PUBLISHER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
$response->assertStatus(403);
// Assert that removing a 'moderator' role from the owner is not possible
$post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
$response->assertStatus(200);
$this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role);
// Assert that non-moderator token does not allow access
$post = ['role' => Room::ROLE_SUBSCRIBER];
$response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
}
/**
* Create a moderator connection to the room session.
*
- * @param \App\Room $room The room
+ * @param \App\OpenVidu\Room $room The room
*
* @return string The connection authentication token
*/
private function getModeratorToken(Room $room): string
{
$result = $room->getSessionToken(Room::ROLE_MODERATOR);
return $result['authToken'];
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Apr 24, 2:22 PM (1 w, 4 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18847588
Default Alt Text
(151 KB)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline