Compare commits

..

6 Commits

Author SHA1 Message Date
sualko e25f969e68
release: 2.2.0-beta.1 🎉 2022-01-05 10:30:23 +01:00
sualko 0469641a30 docs: update change log 2022-01-05 10:29:36 +01:00
sualko 862cda79c3 feat: add command to clear avatar cache 2022-01-05 10:05:26 +01:00
sualko dd87506f22 feat: add optional avatar cache 2022-01-05 09:59:49 +01:00
sualko b7ad4367e2 fix: use injection for random generator 2022-01-05 09:08:34 +01:00
sualko 27aa28da81 refactor: remove deprecated api calls 2022-01-05 02:40:00 +01:00
9 changed files with 293 additions and 17 deletions

View File

@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- add optional avatar cache
- add command to clear avatar cache
### Fixed
- use injection for random generator
### Misc
- remove deprecated api calls
- enable app for unit tests
## 2.1.0 (2021-12-08) ## 2.1.0 (2021-12-08)
### Added ### Added

View File

@ -76,6 +76,44 @@ Key | Description
`api.url` | URL to your BBB server. Should start with `https://` `api.url` | URL to your BBB server. Should start with `https://`
`api.secret` | Secret of your BBB server `api.secret` | Secret of your BBB server
`app.shortener` | Value of your shortener service. Should start with `https://` and contain `{token}`. `app.shortener` | Value of your shortener service. Should start with `https://` and contain `{token}`.
`avatar.path` | Absolute path to an optional avatar cache directory.
`avatar.url` | URL which serves `avatar.path` to be used as avatar cache.
### Avatar cache (v2.2+)
The generation of avatars puts a high load on your Nextcloud instance, since the
number of requests increases squarely to the number of participants in a room.
To mitigate this situation, this app provides an optional avatar file cache. To
activate the cache `avatar.path` and `avatar.url` have to be configured.
`avatar.path` must provide an absolute path (e.g. `/srv/bbb-avatar-cache/`) to a
directory which is writable by the PHP user. `avatar.url` must contain the url
which serves all files from `avatar.path`. To bypass browser connection limits
we recommend to setup a dedicated host.
Example Apache configuration for a dedicated host with `avatar.path = /srv/bbb-avatar-cache/`
and `avatar.url = https://avatar-cache.your-nextcloud.com/`:
```
<VirtualHost *:443>
ServerName avatar-cache.your-nextcloud.com
Header always set Strict-Transport-Security "max-age=15768000;"
DocumentRoot /srv/bbb-avatar-cache
<Directory /srv/bbb-avatar-cache>
Options -FollowSymLinks -Indexes
</Directory>
SSLEngine On
# SSL config...
</VirtualHost>
```
For additional security, we recommend to disable directory listing, symlinks and
any language interpreter such as php for the cache directory.
Cached avatars are usually deleted as soon as the meeting ends. In cases the BBB
server shuts down unexpected, we provide the `bbb:clear-avatar-cache` occ
command (example use: `./occ bbb:clear-avatar-cache`).
## :bowtie: User guide ## :bowtie: User guide

View File

@ -22,7 +22,7 @@ Developer wanted! If you have time it would be awesome if you could help to enha
*This app integrates BigBlueButton and is not endorsed or certified by BigBlueButton Inc. BigBlueButton and the BigBlueButton Logo are trademarks of BigBlueButton Inc.* *This app integrates BigBlueButton and is not endorsed or certified by BigBlueButton Inc. BigBlueButton and the BigBlueButton Logo are trademarks of BigBlueButton Inc.*
]]></description> ]]></description>
<version>2.1.0</version> <version>2.2.0-beta.1</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="klaus@jsxc.org">Klaus Herberth</author> <author mail="klaus@jsxc.org">Klaus Herberth</author>
<namespace>BigBlueButton</namespace> <namespace>BigBlueButton</namespace>
@ -45,6 +45,9 @@ Developer wanted! If you have time it would be awesome if you could help to enha
<lib>SimpleXML</lib> <lib>SimpleXML</lib>
<nextcloud min-version="20" max-version="23"/> <nextcloud min-version="20" max-version="23"/>
</dependencies> </dependencies>
<commands>
<command>OCA\BigBlueButton\Command\ClearAvatarCache</command>
</commands>
<settings> <settings>
<admin>OCA\BigBlueButton\Settings\Admin</admin> <admin>OCA\BigBlueButton\Settings\Admin</admin>
<personal-section>OCA\BigBlueButton\Settings\Section</personal-section> <personal-section>OCA\BigBlueButton\Settings\Section</personal-section>

166
lib/AvatarRepository.php Normal file
View File

@ -0,0 +1,166 @@
<?php
namespace OCA\BigBlueButton;
use OCA\BigBlueButton\AppInfo\Application;
use OCA\BigBlueButton\Db\Room;
use OCP\IAvatarManager;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\ISecureRandom;
class AvatarRepository {
public const CONF_KEY_PATH = 'avatar.path';
public const CONF_KEY_URL = 'avatar.url';
/** @var IAvatarManager */
private $avatarManager;
/** @var ISecureRandom */
private $random;
/** @var IURLGenerator */
private $urlGenerator;
/** @var IConfig */
private $config;
public function __construct(
IAvatarManager $avatarManager,
IURLGenerator $urlGenerator,
ISecureRandom $random,
IConfig $config) {
$this->avatarManager = $avatarManager;
$this->urlGenerator = $urlGenerator;
$this->random = $random;
$this->config = $config;
}
public function getAvatarUrl(Room $room, string $userId): string {
if (!$this->isAvatarCacheConfigured()) {
return $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => 32]);
}
$roomDirName = $room->uid . '-' . $this->generateUrlSafeRandom(16);
$roomDirPath = $this->getRootPath() . $roomDirName . DIRECTORY_SEPARATOR;
if (!file_exists($roomDirPath)) {
if (!mkdir($roomDirPath, 0755, true)) {
throw new \RuntimeException('Can not create room directory: ' . $roomDirPath);
}
file_put_contents($roomDirPath . 'index.html', '');
if (!file_exists($this->getRootPath() . 'index.html')) {
file_put_contents($this->getRootPath() . 'index.html', 'Avatar cache is working.');
}
}
$avatar = $this->avatarManager->getAvatar($userId);
$file = $avatar->getFile(32);
$avatarFileName = $this->generateUrlSafeRandom(16) . '-' . $file->getName();
$avatarPath = $roomDirPath . $avatarFileName;
if (!file_put_contents($avatarPath, $file->getContent())) {
throw new \RuntimeException('Could not write avatar file: ' . $avatarPath);
}
chmod($avatarPath, 0644);
return $this->getBaseUrl() . $roomDirName . '/' . $avatarFileName;
}
public function clearRoom(string $roomUid): int {
if (!$this->isAvatarCacheConfigured() || empty($roomUid)) {
return 0;
}
$fileCounter = 0;
foreach (glob($this->getRootPath() . $roomUid . '-*' . DIRECTORY_SEPARATOR) as $dir) {
foreach (scandir($dir) as $file) {
if (in_array($file, ['.', '..'])) {
continue;
}
unlink($dir . $file);
if ($file !== 'index.html') {
$fileCounter++;
}
}
rmdir($dir);
}
return $fileCounter;
}
public function clearAllRooms(): array {
if (!$this->isAvatarCacheConfigured()) {
return [
'rooms' => 0,
'files' => 0,
];
}
$path = $this->getRootPath();
$roomCounter = 0;
$fileCounter = 0;
foreach (scandir($path) as $dir) {
if (in_array($dir, ['.', '..']) || $dir === 'index.html') {
continue;
}
$roomUid = \explode("-", $dir)[0];
$fileCounter += $this->clearRoom($roomUid);
$roomCounter++;
}
return [
'rooms' => $roomCounter,
'files' => $fileCounter,
];
}
private function generateUrlSafeRandom(int $length): string {
// from Nextcloud 23 ISecureRandom::CHAR_ALPHANUMERIC can be used as shortcut
return $this->random->generate($length, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
}
private function isAvatarCacheConfigured(): bool {
return !empty($this->getRootPath()) && !empty($this->getBaseUrl());
}
private function getRootPath(): string {
$path = $this->config->getAppValue(Application::ID, self::CONF_KEY_PATH);
if (empty($path)) {
return '';
}
return substr($path, -\strlen($path)) === DIRECTORY_SEPARATOR ? $path : ($path . DIRECTORY_SEPARATOR);
}
private function getBaseUrl(): string {
$url = $this->config->getAppValue(Application::ID, self::CONF_KEY_URL);
if (empty($url)) {
return '';
}
if (preg_match('/^https?:\/\//', $url) === 0) {
$url = $this->urlGenerator->getAbsoluteURL($url);
}
if (preg_match('/\/$/', $url) === 0) {
$url .= '/';
}
return $url;
}
}

View File

@ -10,6 +10,7 @@ use BigBlueButton\Parameters\GetRecordingsParameters;
use BigBlueButton\Parameters\IsMeetingRunningParameters; use BigBlueButton\Parameters\IsMeetingRunningParameters;
use BigBlueButton\Parameters\JoinMeetingParameters; use BigBlueButton\Parameters\JoinMeetingParameters;
use OCA\BigBlueButton\AppInfo\Application; use OCA\BigBlueButton\AppInfo\Application;
use OCA\BigBlueButton\AvatarRepository;
use OCA\BigBlueButton\Crypto; use OCA\BigBlueButton\Crypto;
use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Event\MeetingStartedEvent; use OCA\BigBlueButton\Event\MeetingStartedEvent;
@ -50,6 +51,9 @@ class API {
/** @var IAppManager */ /** @var IAppManager */
private $appManager; private $appManager;
/** @var AvatarRepository */
private $avatarRepository;
/** @var IRequest */ /** @var IRequest */
private $request; private $request;
@ -62,6 +66,7 @@ class API {
UrlHelper $urlHelper, UrlHelper $urlHelper,
Defaults $defaults, Defaults $defaults,
IAppManager $appManager, IAppManager $appManager,
AvatarRepository $avatarRepository,
IRequest $request IRequest $request
) { ) {
$this->config = $config; $this->config = $config;
@ -72,6 +77,7 @@ class API {
$this->urlHelper = $urlHelper; $this->urlHelper = $urlHelper;
$this->defaults = $defaults; $this->defaults = $defaults;
$this->appManager = $appManager; $this->appManager = $appManager;
$this->avatarRepository = $avatarRepository;
$this->request = $request; $this->request = $request;
} }
@ -97,7 +103,7 @@ class API {
$joinMeetingParams = new JoinMeetingParameters($room->uid, $displayname, $password); $joinMeetingParams = new JoinMeetingParameters($room->uid, $displayname, $password);
// ensure that float is not converted to a string in scientific notation // ensure that float is not converted to a string in scientific notation
$joinMeetingParams->setCreationTime(sprintf("%.0f", $creationTime)); $joinMeetingParams->setCreateTime(sprintf("%.0f", $creationTime));
$joinMeetingParams->setJoinViaHtml5(true); $joinMeetingParams->setJoinViaHtml5(true);
$joinMeetingParams->setRedirect(true); $joinMeetingParams->setRedirect(true);
$joinMeetingParams->setGuest($uid === null); $joinMeetingParams->setGuest($uid === null);
@ -121,8 +127,10 @@ class API {
} }
if ($uid) { if ($uid) {
$joinMeetingParams->setUserId($uid); $avatarUrl = $this->avatarRepository->getAvatarUrl($room, $uid);
$joinMeetingParams->setAvatarURL($this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $uid, 'size' => 32]));
$joinMeetingParams->setUserID($uid);
$joinMeetingParams->setAvatarURL($avatarUrl);
} }
return $this->getServer()->getJoinMeetingURL($joinMeetingParams); return $this->getServer()->getJoinMeetingURL($joinMeetingParams);
@ -156,11 +164,11 @@ class API {
private function buildMeetingParams(Room $room, Presentation $presentation = null): CreateMeetingParameters { private function buildMeetingParams(Room $room, Presentation $presentation = null): CreateMeetingParameters {
$createMeetingParams = new CreateMeetingParameters($room->uid, $room->name); $createMeetingParams = new CreateMeetingParameters($room->uid, $room->name);
$createMeetingParams->setAttendeePassword($room->attendeePassword); $createMeetingParams->setAttendeePW($room->attendeePassword);
$createMeetingParams->setModeratorPassword($room->moderatorPassword); $createMeetingParams->setModeratorPW($room->moderatorPassword);
$createMeetingParams->setRecord($room->record); $createMeetingParams->setRecord($room->record);
$createMeetingParams->setAllowStartStopRecording($room->record); $createMeetingParams->setAllowStartStopRecording($room->record);
$createMeetingParams->setLogoutUrl($this->urlGenerator->getBaseUrl()); $createMeetingParams->setLogoutURL($this->urlGenerator->getBaseUrl());
$createMeetingParams->setMuteOnStart($room->getJoinMuted()); $createMeetingParams->setMuteOnStart($room->getJoinMuted());
$createMeetingParams->addMeta('bbb-origin-version', $this->appManager->getAppVersion(Application::ID)); $createMeetingParams->addMeta('bbb-origin-version', $this->appManager->getAppVersion(Application::ID));
@ -179,7 +187,7 @@ class API {
$createMeetingParams->setModeratorOnlyMessage($this->l10n->t('To invite someone to the meeting, send them this link: %s', [$invitationUrl])); $createMeetingParams->setModeratorOnlyMessage($this->l10n->t('To invite someone to the meeting, send them this link: %s', [$invitationUrl]));
if (!empty($room->welcome)) { if (!empty($room->welcome)) {
$createMeetingParams->setWelcomeMessage($room->welcome); $createMeetingParams->setWelcome($room->welcome);
} }
if ($room->maxParticipants > 0) { if ($room->maxParticipants > 0) {
@ -200,7 +208,7 @@ class API {
public function getRecording(string $recordId) { public function getRecording(string $recordId) {
$recordingParams = new GetRecordingsParameters(); $recordingParams = new GetRecordingsParameters();
$recordingParams->setRecordId($recordId); $recordingParams->setRecordID($recordId);
$recordingParams->setState('any'); $recordingParams->setState('any');
$response = $this->getServer()->getRecordings($recordingParams); $response = $this->getServer()->getRecordings($recordingParams);
@ -220,7 +228,7 @@ class API {
public function getRecordings(Room $room): array { public function getRecordings(Room $room): array {
$recordingParams = new GetRecordingsParameters(); $recordingParams = new GetRecordingsParameters();
$recordingParams->setMeetingId($room->uid); $recordingParams->setMeetingID($room->uid);
$recordingParams->setState('processing,processed,published,unpublished'); $recordingParams->setState('processing,processed,published,unpublished');
$response = $this->getServer()->getRecordings($recordingParams); $response = $this->getServer()->getRecordings($recordingParams);

View File

@ -0,0 +1,36 @@
<?php
namespace OCA\BigBlueButton\Command;
use OCA\BigBlueButton\AvatarRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ClearAvatarCache extends Command {
/**
* @var AvatarRepository
*/
private $avatarRepository;
public function __construct(
AvatarRepository $avatarRepository
) {
parent::__construct();
$this->avatarRepository = $avatarRepository;
}
protected function configure() {
$this->setName('bbb:clear-avatar-cache');
$this->setDescription('Clear all avatars in cache');
}
protected function execute(InputInterface $input, OutputInterface $output) {
$stats = $this->avatarRepository->clearAllRooms();
$output->writeln("Removed " . $stats["files"] . " avatars in " . $stats["rooms"] . " rooms");
return 0;
}
}

View File

@ -2,6 +2,7 @@
namespace OCA\BigBlueButton\Controller; namespace OCA\BigBlueButton\Controller;
use OCA\BigBlueButton\AvatarRepository;
use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Event\MeetingEndedEvent; use OCA\BigBlueButton\Event\MeetingEndedEvent;
use OCA\BigBlueButton\Event\RecordingReadyEvent; use OCA\BigBlueButton\Event\RecordingReadyEvent;
@ -20,6 +21,9 @@ class HookController extends Controller {
/** @var RoomService */ /** @var RoomService */
private $service; private $service;
/** @var AvatarRepository */
private $avatarRepository;
/** @var IEventDispatcher */ /** @var IEventDispatcher */
private $eventDispatcher; private $eventDispatcher;
@ -27,11 +31,13 @@ class HookController extends Controller {
string $appName, string $appName,
IRequest $request, IRequest $request,
RoomService $service, RoomService $service,
AvatarRepository $avatarRepository,
IEventDispatcher $eventDispatcher IEventDispatcher $eventDispatcher
) { ) {
parent::__construct($appName, $request); parent::__construct($appName, $request);
$this->service = $service; $this->service = $service;
$this->avatarRepository = $avatarRepository;
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
} }
@ -55,8 +61,11 @@ class HookController extends Controller {
*/ */
public function meetingEnded($recordingmarks = false): void { public function meetingEnded($recordingmarks = false): void {
$recordingmarks = \boolval($recordingmarks); $recordingmarks = \boolval($recordingmarks);
$room = $this->getRoom();
$this->eventDispatcher->dispatch(MeetingEndedEvent::class, new MeetingEndedEvent($this->getRoom(), $recordingmarks)); $this->avatarRepository->clearRoom($room->uid);
$this->eventDispatcher->dispatch(MeetingEndedEvent::class, new MeetingEndedEvent($room, $recordingmarks));
} }
/** /**

View File

@ -13,6 +13,7 @@ use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig; use OCP\IConfig;
use OCP\Security\ISecureRandom;
class RoomService { class RoomService {
@ -25,13 +26,18 @@ class RoomService {
/** @var IEventDispatcher */ /** @var IEventDispatcher */
private $eventDispatcher; private $eventDispatcher;
/** @var ISecureRandom */
private $random;
public function __construct( public function __construct(
RoomMapper $mapper, RoomMapper $mapper,
IConfig $config, IConfig $config,
IEventDispatcher $eventDispatcher) { IEventDispatcher $eventDispatcher,
ISecureRandom $random) {
$this->mapper = $mapper; $this->mapper = $mapper;
$this->config = $config; $this->config = $config;
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
$this->random = $random;
} }
public function findAll(string $userId, array $groupIds, array $circleIds): array { public function findAll(string $userId, array $groupIds, array $circleIds): array {
@ -84,12 +90,12 @@ class RoomService {
$mediaCheck = $this->config->getAppValue('bbb', 'join.mediaCheck', 'true') === 'true'; $mediaCheck = $this->config->getAppValue('bbb', 'join.mediaCheck', 'true') === 'true';
$room->setUid(\OC::$server->getSecureRandom()->generate(16, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setUid($this->humanReadableRandom(16));
$room->setName($name); $room->setName($name);
$room->setWelcome($welcome); $room->setWelcome($welcome);
$room->setMaxParticipants(\max($maxParticipants, 0)); $room->setMaxParticipants(\max($maxParticipants, 0));
$room->setAttendeePassword(\OC::$server->getSecureRandom()->generate(32, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setAttendeePassword($this->humanReadableRandom(32));
$room->setModeratorPassword(\OC::$server->getSecureRandom()->generate(32, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setModeratorPassword($this->humanReadableRandom(32));
$room->setRecord($record); $room->setRecord($record);
$room->setAccess($access); $room->setAccess($access);
$room->setUserId($userId); $room->setUserId($userId);
@ -178,6 +184,6 @@ class RoomService {
* @param int $length * @param int $length
*/ */
private function humanReadableRandom(int $length) { private function humanReadableRandom(int $length) {
return \OC::$server->getSecureRandom()->generate($length, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE); return $this->random->generate($length, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE);
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@sualko/cloud_bbb", "name": "@sualko/cloud_bbb",
"description": "Nextcloud Integration for BigBlueButton", "description": "Nextcloud Integration for BigBlueButton",
"version": "2.1.0", "version": "2.2.0-beta.1",
"author": "Klaus Herberth <klaus@jsxc.org>", "author": "Klaus Herberth <klaus@jsxc.org>",
"bugs": { "bugs": {
"url": "https://github.com/sualko/cloud_bbb/issues" "url": "https://github.com/sualko/cloud_bbb/issues"