diff --git a/README.md b/README.md index 9db5e98..6916ba9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,40 @@ Key | Description `api.url` | URL to your BBB server. Should start with `https://` `api.secret` | Secret of your BBB server `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/`: + +``` + + ServerName avatar-cache.your-nextcloud.com + + Header always set Strict-Transport-Security "max-age=15768000;" + + DocumentRoot /srv/bbb-avatar-cache + + Options -FollowSymLinks -Indexes + + + SSLEngine On + # SSL config... + +``` + +For additional security, we recommend to disable directory listing, symlinks and +any language interpreter such as php for the cache directory. ## :bowtie: User guide diff --git a/lib/AvatarRepository.php b/lib/AvatarRepository.php new file mode 100644 index 0000000..38da1e7 --- /dev/null +++ b/lib/AvatarRepository.php @@ -0,0 +1,166 @@ +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; + } +} diff --git a/lib/BigBlueButton/API.php b/lib/BigBlueButton/API.php index 0a95d5c..ade4139 100644 --- a/lib/BigBlueButton/API.php +++ b/lib/BigBlueButton/API.php @@ -10,6 +10,7 @@ use BigBlueButton\Parameters\GetRecordingsParameters; use BigBlueButton\Parameters\IsMeetingRunningParameters; use BigBlueButton\Parameters\JoinMeetingParameters; use OCA\BigBlueButton\AppInfo\Application; +use OCA\BigBlueButton\AvatarRepository; use OCA\BigBlueButton\Crypto; use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Event\MeetingStartedEvent; @@ -50,6 +51,9 @@ class API { /** @var IAppManager */ private $appManager; + /** @var AvatarRepository */ + private $avatarRepository; + /** @var IRequest */ private $request; @@ -62,6 +66,7 @@ class API { UrlHelper $urlHelper, Defaults $defaults, IAppManager $appManager, + AvatarRepository $avatarRepository, IRequest $request ) { $this->config = $config; @@ -72,6 +77,7 @@ class API { $this->urlHelper = $urlHelper; $this->defaults = $defaults; $this->appManager = $appManager; + $this->avatarRepository = $avatarRepository; $this->request = $request; } @@ -121,8 +127,10 @@ class API { } if ($uid) { - $joinMeetingParams->setUserId($uid); - $joinMeetingParams->setAvatarURL($this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $uid, 'size' => 32])); + $avatarUrl = $this->avatarRepository->getAvatarUrl($room, $uid); + + $joinMeetingParams->setUserID($uid); + $joinMeetingParams->setAvatarURL($avatarUrl); } return $this->getServer()->getJoinMeetingURL($joinMeetingParams); diff --git a/lib/Controller/HookController.php b/lib/Controller/HookController.php index 3769b32..98c6018 100644 --- a/lib/Controller/HookController.php +++ b/lib/Controller/HookController.php @@ -2,6 +2,7 @@ namespace OCA\BigBlueButton\Controller; +use OCA\BigBlueButton\AvatarRepository; use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Event\MeetingEndedEvent; use OCA\BigBlueButton\Event\RecordingReadyEvent; @@ -20,6 +21,9 @@ class HookController extends Controller { /** @var RoomService */ private $service; + /** @var AvatarRepository */ + private $avatarRepository; + /** @var IEventDispatcher */ private $eventDispatcher; @@ -27,11 +31,13 @@ class HookController extends Controller { string $appName, IRequest $request, RoomService $service, + AvatarRepository $avatarRepository, IEventDispatcher $eventDispatcher ) { parent::__construct($appName, $request); $this->service = $service; + $this->avatarRepository = $avatarRepository; $this->eventDispatcher = $eventDispatcher; } @@ -55,8 +61,11 @@ class HookController extends Controller { */ public function meetingEnded($recordingmarks = false): void { $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)); } /**