feat: add access policy

public, require password for guests, moderator approval
for guests, only Nextcloud users

fix #10
fix #24
pull/63/head
sualko 2020-06-04 18:56:55 +02:00
parent 07f679aa09
commit 70b06aa98c
16 changed files with 253 additions and 45 deletions

View File

@ -1,3 +1,15 @@
<?php <?php
OCP\Util::addScript ( 'bbb', 'filelist'); OCP\Util::addScript ( 'bbb', 'filelist');
$apiUrl = \OC::$server->getConfig()->getAppValue('bbb', 'api.url');
$parsedApiUrl = @parse_url($apiUrl);
if ($parsedApiUrl !== false) {
$manager = \OC::$server->getContentSecurityPolicyManager();
$policy = new \OCP\AppFramework\Http\EmptyContentSecurityPolicy();
$policy->addAllowedFormActionDomain(($parsedApiUrl['scheme'] ?: 'https') . '://' . $parsedApiUrl['host']);
$manager->addDefaultPolicy($policy);
}

View File

@ -58,6 +58,7 @@ class API
$joinMeetingParams->setCreationTime($creationTime); $joinMeetingParams->setCreationTime($creationTime);
$joinMeetingParams->setJoinViaHtml5(true); $joinMeetingParams->setJoinViaHtml5(true);
$joinMeetingParams->setRedirect(true); $joinMeetingParams->setRedirect(true);
$joinMeetingParams->setGuest($uid === null);
if ($uid) { if ($uid) {
$joinMeetingParams->setUserId($uid); $joinMeetingParams->setUserId($uid);
@ -114,6 +115,10 @@ class API
$createMeetingParams->addPresentation($presentation->getUrl(), null, $presentation->getFilename()); $createMeetingParams->addPresentation($presentation->getUrl(), null, $presentation->getFilename());
} }
if ($room->access === Room::ACCESS_WAITING_ROOM) {
$createMeetingParams->setGuestPolicyAskModerator();
}
return $createMeetingParams; return $createMeetingParams;
} }

View File

@ -8,6 +8,7 @@ use OCA\BigBlueButton\NotFoundException;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\IRequest; use OCP\IRequest;
use OCP\ISession; use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession; use OCP\IUserSession;
use OCP\IConfig; use OCP\IConfig;
use OCA\BigBlueButton\Service\RoomService; use OCA\BigBlueButton\Service\RoomService;
@ -25,6 +26,9 @@ class JoinController extends Controller
/** @var RoomService */ /** @var RoomService */
private $service; private $service;
/** @var IURLGenerator */
private $urlGenerator;
/** @var IUserSession */ /** @var IUserSession */
private $userSession; private $userSession;
@ -39,6 +43,7 @@ class JoinController extends Controller
IRequest $request, IRequest $request,
ISession $session, ISession $session,
RoomService $service, RoomService $service,
IURLGenerator $urlGenerator,
IUserSession $userSession, IUserSession $userSession,
IConfig $config, IConfig $config,
API $api API $api
@ -46,6 +51,7 @@ class JoinController extends Controller
parent::__construct($appName, $request, $session); parent::__construct($appName, $request, $session);
$this->service = $service; $this->service = $service;
$this->urlGenerator = $urlGenerator;
$this->userSession = $userSession; $this->userSession = $userSession;
$this->config = $config; $this->config = $config;
$this->api = $api; $this->api = $api;
@ -68,7 +74,7 @@ class JoinController extends Controller
* @PublicPage * @PublicPage
* @NoCSRFRequired * @NoCSRFRequired
*/ */
public function index($displayname, $u = '', $filename = '') public function index($displayname, $u = '', $filename = '', $password = '')
{ {
$room = $this->getRoom(); $room = $this->getRoom();
@ -87,10 +93,21 @@ class JoinController extends Controller
if ($userId === $room->userId) { if ($userId === $room->userId) {
$presentation = new Presentation($u, $filename); $presentation = new Presentation($u, $filename);
} }
} elseif (empty($displayname) || strlen($displayname) < 3) { } elseif ($room->access === Room::ACCESS_INTERNAL) {
$response = new TemplateResponse($this->appName, 'publicdisplayname', [ return new RedirectResponse(
$this->urlGenerator->linkToRoute('core.login.showLoginForm', [
'redirect_url' => $this->urlGenerator->linkToRoute(
'bbb.join.index',
['token' => $this->token]
),
])
);
} elseif (empty($displayname) || strlen($displayname) < 3 || ($room->access === Room::ACCESS_PASSWORD && $password !== $room->password)) {
$response = new TemplateResponse($this->appName, 'join', [
'room' => $room->name, 'room' => $room->name,
'wrongdisplayname' => !empty($displayname) && strlen($displayname) < 3 'wrongdisplayname' => !empty($displayname) && strlen($displayname) < 3,
'passwordRequired' => $room->access === Room::ACCESS_PASSWORD,
'wrongPassword' => $password !== $room->password && $password !== '',
], 'guest'); ], 'guest');
$this->addFormActionDomain($response); $this->addFormActionDomain($response);
@ -116,7 +133,7 @@ class JoinController extends Controller
$response->getContentSecurityPolicy()->addAllowedFormActionDomain(($parsedApiUrl['scheme'] ?: 'https') . '://' . $parsedApiUrl['host']); $response->getContentSecurityPolicy()->addAllowedFormActionDomain(($parsedApiUrl['scheme'] ?: 'https') . '://' . $parsedApiUrl['host']);
} }
private function getRoom(): Room private function getRoom(): ?Room
{ {
if ($this->room === null) { if ($this->room === null) {
$this->room = $this->service->findByUid($this->token); $this->room = $this->service->findByUid($this->token);

View File

@ -81,10 +81,11 @@ class RoomApiController extends ApiController
string $name, string $name,
string $welcome, string $welcome,
int $maxParticipants, int $maxParticipants,
bool $record bool $record,
string $access
): DataResponse { ): DataResponse {
return $this->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record) { return $this->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record, $access) {
return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $this->userId); return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $access, $this->userId);
}); });
} }

View File

@ -73,10 +73,11 @@ class RoomController extends Controller
string $name, string $name,
string $welcome, string $welcome,
int $maxParticipants, int $maxParticipants,
bool $record bool $record,
string $access
): DataResponse { ): DataResponse {
return $this->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record) { return $this->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record, $access) {
return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $this->userId); return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $access, $this->userId);
}); });
} }

View File

@ -7,6 +7,12 @@ use OCP\AppFramework\Db\Entity;
class Room extends Entity implements JsonSerializable class Room extends Entity implements JsonSerializable
{ {
const ACCESS_PUBLIC = 'public';
const ACCESS_PASSWORD = 'password';
const ACCESS_WAITING_ROOM = 'waiting_room';
const ACCESS_INTERNAL = 'internal';
const ACCESS_INTERNAL_RESTRICTED = 'internal_restricted';
public $uid; public $uid;
public $name; public $name;
public $attendeePassword; public $attendeePassword;
@ -15,6 +21,8 @@ class Room extends Entity implements JsonSerializable
public $maxParticipants; public $maxParticipants;
public $record; public $record;
public $userId; public $userId;
public $access;
public $password;
public function __construct() public function __construct()
{ {
@ -31,6 +39,8 @@ class Room extends Entity implements JsonSerializable
'welcome' => $this->welcome, 'welcome' => $this->welcome,
'maxParticipants' => (int) $this->maxParticipants, 'maxParticipants' => (int) $this->maxParticipants,
'record' => boolval($this->record), 'record' => boolval($this->record),
'access' => $this->access,
'password' => $this->password,
]; ];
} }
} }

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace OCA\BigBlueButton\Migration;
use Closure;
use OCA\BigBlueButton\Db\Room;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version000000Date20200604130935 extends SimpleMigrationStep
{
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options)
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('bbb_rooms')) {
$table = $schema->getTable('bbb_rooms');
if (!$table->hasColumn('access')) {
$table->addColumn('access', 'string', [
'notnull' => true,
'default' => Room::ACCESS_PUBLIC,
]);
}
if (!$table->hasColumn('password')) {
$table->addColumn('password', 'string', [
'length' => 64,
'notnull' => false,
]);
}
return $schema;
}
return null;
}
}

View File

@ -75,15 +75,20 @@ class RoomService
return $this->mapper->insert($room); return $this->mapper->insert($room);
} }
public function update($id, $name, $welcome, $maxParticipants, $record, $userId) public function update($id, $name, $welcome, $maxParticipants, $record, $access, $userId)
{ {
try { try {
$room = $this->mapper->find($id, $userId); $room = $this->mapper->find($id, $userId);
if ($room->access !== $access) {
$room->setPassword($access === Room::ACCESS_PASSWORD ? $this->humanReadableRandom(8) : null);
}
$room->setName($name); $room->setName($name);
$room->setWelcome($welcome); $room->setWelcome($welcome);
$room->setMaxParticipants($maxParticipants); $room->setMaxParticipants($maxParticipants);
$room->setRecord($record); $room->setRecord($record);
$room->setAccess($access);
$room->setUserId($userId); $room->setUserId($userId);
return $this->mapper->update($room); return $this->mapper->update($room);
@ -102,4 +107,9 @@ class RoomService
$this->handleException($e); $this->handleException($e);
} }
} }
private function humanReadableRandom($length)
{
return \OC::$server->getSecureRandom()->generate($length, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE);
}
} }

38
templates/join.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/** @var $_ array */
/** @var $l \OCP\IL10N */
style('core', 'guest');
script('bbb', 'join');
?>
<form method="get" action="?">
<fieldset class="warning bbb">
<h2><?php p($_['room']) ?></h2>
<?php if (!isset($_['wrongdisplayname']) || !$_['wrongdisplayname']): ?>
<p><?php p($l->t('Please enter your name!')); ?></p>
<?php endif; ?>
<?php if (isset($_['wrongdisplayname']) && $_['wrongdisplayname']): ?>
<div class="warning"><?php p($l->t('The name must be at least 3 characters long.')); ?></div>
<?php endif; ?>
<?php if (isset($_['wrongPassword']) && $_['wrongPassword']): ?>
<div class="warning"><?php p($l->t('You have to provide the correct password to join the meeting.')); ?></div>
<?php endif; ?>
<div class="bbb-container">
<label for="displayname" class="infield"><?php p($l->t('Display name')); ?></label>
<input type="text" name="displayname" id="displayname" class="bbb-input"
placeholder="<?php p($l->t('Display name')); ?>" value=""
required minlength="3" autofocus />
<?php if (isset($_['passwordRequired']) && $_['passwordRequired']): ?>
<label for="password" class="infield"><?php p($l->t('Password')); ?></label>
<input type="text" name="password" id="password" class="bbb-input"
placeholder="<?php p($l->t('Password')); ?>" value=""
required minlength="8" />
<button class="primary"><?php p($l->t('Join')); ?>
<div class="submit-icon icon-confirm-white"></div></button>
<?php else: ?>
<input type="submit" id="displayname-submit"
class="svg icon-confirm input-button-inline" value="" />
<?php endif; ?>
</div>
</fieldset>
</form>

View File

@ -1,25 +0,0 @@
<?php
/** @var $_ array */
/** @var $l \OCP\IL10N */
style('core', 'guest');
style('core', 'publicshareauth');
?>
<form method="get" action="?">
<fieldset class="warning">
<h2><?php p($_['room']) ?></h2>
<?php if (!isset($_['wrongdisplayname']) || !$_['wrongdisplayname']): ?>
<p><?php p($l->t('Please enter your name!')); ?></p>
<?php endif; ?>
<?php if (isset($_['wrongdisplayname']) && $_['wrongdisplayname']): ?>
<div class="warning"><?php p($l->t('The name must be at least 3 characters long.')); ?></div>
<?php endif; ?>
<p>
<label for="password" class="infield"><?php p($l->t('Display name')); ?></label>
<input type="displayname" name="displayname" id="password"
placeholder="<?php p($l->t('Display name')); ?>" value=""
required minlength="3" autofocus />
<input type="submit" id="displayname-submit"
class="svg icon-confirm input-button-inline" value="" />
</p>
</fieldset>
</form>

View File

@ -1,5 +1,13 @@
import axios from '@nextcloud/axios'; import axios from '@nextcloud/axios';
export enum Access {
Public = 'public',
Password = 'password',
WaitingRoom = 'waiting_room',
Internal = 'internal',
InternalRestricted = 'internal_restricted',
}
export interface Room { export interface Room {
id: number; id: number;
uid: string; uid: string;
@ -7,6 +15,8 @@ export interface Room {
welcome: string; welcome: string;
maxParticipants: number; maxParticipants: number;
record: boolean; record: boolean;
access: Access;
password?: string;
} }
export type Recording = { export type Recording = {

View File

@ -168,8 +168,14 @@
min-width: 300px; min-width: 300px;
margin: 2em 0; margin: 2em 0;
input:not([type="checkbox"]) { input:not([type="checkbox"]),
select {
width: 100%; width: 100%;
display: block;
}
[readonly] {
background-color: #f1f1f1;
} }
em { em {

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Dialog from './Dialog'; import Dialog from './Dialog';
import { Room } from './Api'; import { Room, Access } from './Api';
import { SubmitInput } from './SubmitInput'; import { SubmitInput } from './SubmitInput';
const descriptions: { [key: string]: string } = { const descriptions: { [key: string]: string } = {
@ -8,6 +8,7 @@ const descriptions: { [key: string]: string } = {
welcome: t('bbb', 'This message is shown to all users in the chat area after they joined.'), welcome: t('bbb', 'This message is shown to all users in the chat area after they joined.'),
maxParticipants: t('bbb', 'Sets a limit on the number of participants for this room. Zero means there is no limit.'), maxParticipants: t('bbb', 'Sets a limit on the number of participants for this room. Zero means there is no limit.'),
recording: t('bbb', 'If enabled, the moderator is able to start the recording.'), recording: t('bbb', 'If enabled, the moderator is able to start the recording.'),
access: t('bbb', 'Public: Everyone knowing the link is able to join. Password: Guests have to provide a password. Waiting room: A moderator has to accept every guest before they can join. Internal: Only Nextcloud users can join.'),
}; };
type Props = { type Props = {
@ -18,11 +19,11 @@ type Props = {
const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => { const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
function formElement(label: string, field: string, type: 'text' | 'number' = 'text') { function inputElement(label: string, field: string, type: 'text' | 'number' = 'text') {
return ( return (
<div className="bbb-form-element"> <div className="bbb-form-element">
<label htmlFor={`bbb-${field}`}> <label htmlFor={`bbb-${field}`}>
<h3>{t('bbb', label)}</h3> <h3>{label}</h3>
</label> </label>
<SubmitInput initialValue={room[field]} type={type} name={field} onSubmitValue={value => updateProperty(field, value)} /> <SubmitInput initialValue={room[field]} type={type} name={field} onSubmitValue={value => updateProperty(field, value)} />
@ -31,6 +32,26 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
); );
} }
function selectElement(label: string, field: string, value: string, options: {[key: string]: string}, onChange: (value: string) => void) {
return (
<div className="bbb-form-element">
<label htmlFor={`bbb-${field}`}>
<h3>{label}</h3>
</label>
<select name={field} value={value} onChange={(event) => onChange(event.target.value)}>
{Object.keys(options).map(key => {
const label = options[key];
return <option key={key} value={key}>{label}</option>;
})}
</select>
{(value === Access.Password && room.password) && <input type="text" readOnly={true} value={room.password} />}
{descriptions[field] && <em>{descriptions[field]}</em>}
</div>
);
}
return ( return (
<> <>
<a className="icon icon-edit icon-visible" <a className="icon icon-edit icon-visible"
@ -38,9 +59,20 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
title={t('bbb', 'Edit')} /> title={t('bbb', 'Edit')} />
<Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}> <Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}>
{formElement('Name', 'name')} {inputElement(t('bbb', 'Name'), 'name')}
{formElement('Welcome', 'welcome')} {inputElement(t('bbb', 'Welcome'), 'welcome')}
{formElement('Participant limit', 'maxParticipants', 'number')} {inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')}
{selectElement(t('bbb', 'Access'), 'access', room.access, {
[Access.Public]: t('bbb', 'Public'),
[Access.Password]: t('bbb', 'Internal + Password protection for guests'),
[Access.WaitingRoom]: t('bbb', 'Internal + Waiting room for guests'),
[Access.Internal]: t('bbb', 'Internal'),
// [Access.InternalRestricted]: t('bbb', 'Restricted'),
}, (value) => {
console.log('access', value);
updateProperty('access', value);
})}
<div> <div>
<div> <div>

36
ts/join.scss Normal file
View File

@ -0,0 +1,36 @@
.bbb {
#displayname,
#password {
margin: 5px 0;
padding-right: 45px;
height: 45px;
box-sizing: border-box;
flex: 1 1 auto;
width: 100% !important;
min-width: 0;
}
button {
width: 100%;
margin-top: 1em;
}
.submit-icon {
float: right;
}
.bbb-container {
position: relative;
margin-top: 1em;
}
input[type='submit'].icon-confirm {
position: absolute;
top: 0px;
right: -5px;
width: 45px !important;
height: 45px;
background-color: transparent !important;
}
}

1
ts/join.ts Normal file
View File

@ -0,0 +1 @@
import './join.scss';

View File

@ -11,6 +11,9 @@ module.exports = {
manager: [ manager: [
path.join(__dirname, 'ts', 'Manager', 'index.tsx'), path.join(__dirname, 'ts', 'Manager', 'index.tsx'),
], ],
join: [
path.join(__dirname, 'ts', 'join.ts'),
]
}, },
output: { output: {
path: path.resolve(__dirname, './js'), path: path.resolve(__dirname, './js'),