feat: add optional avatar cache

pull/186/head
sualko 2022-01-05 09:59:49 +01:00
parent b7ad4367e2
commit dd87506f22
4 changed files with 220 additions and 3 deletions

View File

@ -76,6 +76,40 @@ 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.
## :bowtie: User guide ## :bowtie: User guide

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;
} }
@ -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);

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));
} }
/** /**