diff --git a/appinfo/info.xml b/appinfo/info.xml index 0c2a11e..807bec9 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -43,6 +43,11 @@ The official whiteboard app for Nextcloud. It allows users to create and share w + + OCA\Whiteboard\BackgroundJob\WatchActiveUsers + OCA\Whiteboard\BackgroundJob\PruneOldStatisticsData + + OCA\Whiteboard\Settings\Admin OCA\Whiteboard\Settings\Section diff --git a/composer.json b/composer.json index 5463952..4ef687e 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "license": "AGPL", "require": { "php": "^8.0", - "firebase/php-jwt": "^6.10" + "firebase/php-jwt": "^6.10", + "ext-curl": "*" }, "require-dev": { "nextcloud/coding-standard": "^1.0", diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 766dd31..398f87e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,14 +12,20 @@ use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Viewer\Event\LoadViewer; +use OCA\Whiteboard\Events\WhiteboardOpenedEvent; +use OCA\Whiteboard\Events\WhiteboardUpdatedEvent; use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; +use OCA\Whiteboard\Listener\FileCreatedListener; use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; +use OCA\Whiteboard\Listener\WhiteboardOpenedListener; +use OCA\Whiteboard\Listener\WhiteboardUpdatedListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Files\Events\Node\NodeCreatedEvent; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\RegisterTemplateCreatorEvent; use OCP\IL10N; @@ -44,6 +50,9 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(NodeCreatedEvent::class, FileCreatedListener::class); + $context->registerEventListener(WhiteboardOpenedEvent::class, WhiteboardOpenedListener::class); + $context->registerEventListener(WhiteboardUpdatedEvent::class, WhiteboardUpdatedListener::class); } public function boot(IBootContext $context): void { diff --git a/lib/BackgroundJob/PruneOldStatisticsData.php b/lib/BackgroundJob/PruneOldStatisticsData.php new file mode 100644 index 0000000..70d4844 --- /dev/null +++ b/lib/BackgroundJob/PruneOldStatisticsData.php @@ -0,0 +1,38 @@ +setInterval(24 * 60 * 60); + } + + protected function run($argument) { + $lifeTimeInDays = $this->configService->getStatisticsDataLifetime(); + + if (!$lifeTimeInDays) { + return; + } + + $beforeTime = time() - $lifeTimeInDays * 24 * 60 * 60; + $this->statsService->pruneData($beforeTime); + } +} diff --git a/lib/BackgroundJob/WatchActiveUsers.php b/lib/BackgroundJob/WatchActiveUsers.php new file mode 100644 index 0000000..76980fe --- /dev/null +++ b/lib/BackgroundJob/WatchActiveUsers.php @@ -0,0 +1,75 @@ +setInterval(300); + } + + protected function run($argument) { + if (!$this->configService->getWhiteboardEnableStatistics()) { + return; + } + $metricsData = $this->getMetricsData(); + $activeUsers = $metricsData['totalUsers'] ?? 0; + $this->statsService->insertActiveUsersCount($activeUsers); + } + + private function getMetricsData(): array { + $serverUrl = $this->configService->getCollabBackendUrl(); + $metricToken = $this->configService->getCollabBackendMetricsToken(); + + if (!$serverUrl || !$metricToken) { + return []; + } + + $client = $this->clientService->newClient(); + $response = $client->get($serverUrl . '/metrics', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $metricToken, + ], + ]); + $responseBody = $response->getBody(); + + $metrics = [ + 'totalUsers' => 10, + ]; + + if (!is_string($responseBody)) { + return $metrics; + } + + foreach (explode("\n", $responseBody) as $line) { + if (strpos($line, 'socket_io_connected') === false) { + continue; + } + $parts = explode(' ', $line); + $metrics['totalUsers'] = (int)$parts[1]; + } + + return $metrics; + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index da3afcd..0310a63 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -35,6 +35,9 @@ public function update(): DataResponse { try { $serverUrl = $this->request->getParam('serverUrl'); $secret = $this->request->getParam('secret'); + $enableStatistics = $this->request->getParam('enableStatistics'); + $metricsToken = $this->request->getParam('metricsToken'); + $statisticsDataLifetime = $this->request->getParam('statisticsDataLifetime'); if ($serverUrl !== null) { $this->configService->setCollabBackendUrl($serverUrl); @@ -44,6 +47,18 @@ public function update(): DataResponse { $this->configService->setWhiteboardSharedSecret($secret); } + if ($enableStatistics !== null) { + $this->configService->setWhiteboardEnableStatistics($enableStatistics); + } + + if ($metricsToken !== null) { + $this->configService->setCollabBackendMetricsToken($metricsToken); + } + + if ($statisticsDataLifetime !== null) { + $this->configService->setStatisticsDataLifetime((int)$statisticsDataLifetime); + } + return new DataResponse([ 'jwt' => $this->jwtService->generateJWTFromPayload([ 'serverUrl' => $serverUrl ]) ]); diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 47199bc..79006bf 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -10,6 +10,8 @@ namespace OCA\Whiteboard\Controller; use Exception; +use OCA\Whiteboard\Events\WhiteboardOpenedEvent; +use OCA\Whiteboard\Events\WhiteboardUpdatedEvent; use OCA\Whiteboard\Exception\InvalidUserException; use OCA\Whiteboard\Exception\UnauthorizedException; use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory; @@ -23,6 +25,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; /** @@ -39,6 +42,7 @@ public function __construct( private WhiteboardContentService $contentService, private ExceptionService $exceptionService, private ConfigService $configService, + private IEventDispatcher $dispatcher, ) { parent::__construct($appName, $request); } @@ -58,6 +62,9 @@ public function show(int $fileId): DataResponse { $data = $this->contentService->getContent($file); + $event = new WhiteboardOpenedEvent($file, $user, $data); + $this->dispatcher->dispatchTyped($event); + return new DataResponse(['data' => $data]); } catch (Exception $e) { return $this->exceptionService->handleException($e); @@ -79,6 +86,9 @@ public function update(int $fileId, array $data): DataResponse { $this->contentService->updateContent($file, $data); + $event = new WhiteboardUpdatedEvent($file, $user, $data); + $this->dispatcher->dispatchTyped($event); + return new DataResponse(['status' => 'success']); } catch (Exception $e) { return $this->exceptionService->handleException($e); diff --git a/lib/Events/AbstractWhiteboardEvent.php b/lib/Events/AbstractWhiteboardEvent.php new file mode 100644 index 0000000..6ddbe57 --- /dev/null +++ b/lib/Events/AbstractWhiteboardEvent.php @@ -0,0 +1,35 @@ +file; + } + + public function getUser(): User { + return $this->user; + } + + public function getData(): array { + return $this->data; + } +} diff --git a/lib/Events/WhiteboardOpenedEvent.php b/lib/Events/WhiteboardOpenedEvent.php new file mode 100644 index 0000000..007c52f --- /dev/null +++ b/lib/Events/WhiteboardOpenedEvent.php @@ -0,0 +1,13 @@ + */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ +final class FileCreatedListener implements IEventListener { + public function __construct( + protected StatsService $statsService, + protected IUserSession $userSession, + protected ConfigService $configService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof NodeCreatedEvent) || !$this->configService->getWhiteboardEnableStatistics()) { + return; + } + + $node = $event->getNode(); + + if ($node->getExtension() !== 'whiteboard') { + return; + } + + $currentUser = $this->userSession->getUser(); + $ownerUser = $node->getOwner(); + + $this->statsService->insertEvent([ + 'user' => $currentUser ? $currentUser->getUID() : ($ownerUser ? $ownerUser->getUID() : null), + 'type' => 'created', + 'share_token' => '', + 'fileid' => $node->getId(), + 'elements' => json_encode([]), + 'size' => 0, + 'timestamp' => time(), + ]); + } +} diff --git a/lib/Listener/WhiteboardOpenedListener.php b/lib/Listener/WhiteboardOpenedListener.php new file mode 100644 index 0000000..7367cff --- /dev/null +++ b/lib/Listener/WhiteboardOpenedListener.php @@ -0,0 +1,51 @@ + */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ +final class WhiteboardOpenedListener implements IEventListener { + public function __construct( + protected StatsService $statsService, + protected ConfigService $configService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof WhiteboardOpenedEvent) || !$this->configService->getWhiteboardEnableStatistics()) { + return; + } + + $user = $event->getUser(); + $file = $event->getFile(); + $data = $event->getData(); + + $this->statsService->insertEvent([ + 'user' => $user->getUID(), + 'type' => 'opened', + 'share_token' => $user instanceof PublicSharingUser ? $user->getPublicSharingToken() : '', + 'fileid' => $file->getId(), + 'elements' => count($data['elements']), + 'size' => $file->getSize(), + 'timestamp' => time(), + ]); + } +} diff --git a/lib/Listener/WhiteboardUpdatedListener.php b/lib/Listener/WhiteboardUpdatedListener.php new file mode 100644 index 0000000..1a1d3aa --- /dev/null +++ b/lib/Listener/WhiteboardUpdatedListener.php @@ -0,0 +1,51 @@ + */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ +final class WhiteboardUpdatedListener implements IEventListener { + public function __construct( + protected StatsService $statsService, + protected ConfigService $configService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof WhiteboardUpdatedEvent) || !$this->configService->getWhiteboardEnableStatistics()) { + return; + } + + $user = $event->getUser(); + $file = $event->getFile(); + $data = $event->getData(); + + $this->statsService->insertEvent([ + 'user' => $user->getUID(), + 'type' => 'updated', + 'share_token' => $user instanceof PublicSharingUser ? $user->getPublicSharingToken() : '', + 'fileid' => $file->getId(), + 'elements' => count($data['elements']), + 'size' => $file->getSize(), + 'timestamp' => time(), + ]); + } +} diff --git a/lib/Migration/Version1000Date20241213132620.php b/lib/Migration/Version1000Date20241213132620.php new file mode 100644 index 0000000..fc1a139 --- /dev/null +++ b/lib/Migration/Version1000Date20241213132620.php @@ -0,0 +1,111 @@ +hasTable('whiteboard_events')) { + $table = $schema->createTable('whiteboard_events'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('user', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('share_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('fileid', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + ]); + $table->addColumn('elements', Types::INTEGER, [ + 'notnull' => false, + 'length' => 11, + 'default' => 0, + ]); + $table->addColumn('size', Types::INTEGER, [ + 'notnull' => false, + 'length' => 11, + 'default' => 0, + ]); + $table->addColumn('timestamp', Types::INTEGER, [ + 'notnull' => true, + 'length' => 11, + 'default' => 0, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user'], 'whiteboard_user_index'); + $table->addIndex(['fileid'], 'whiteboard_fileid_index'); + } + + if (!$schema->hasTable('whiteboard_active_users')) { + $table = $schema->createTable('whiteboard_active_users'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('total_users', Types::INTEGER, [ + 'notnull' => false, + 'length' => 11, + 'default' => 0, + ]); + $table->addColumn('timestamp', Types::INTEGER, [ + 'notnull' => true, + 'length' => 11, + 'default' => 0, + ]); + $table->setPrimaryKey(['id'], 'whiteboard_active_users_pk'); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 1e46d0c..1aaeedc 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -54,4 +54,37 @@ public function setWhiteboardSharedSecret(string $jwtSecretKey): void { $this->appConfig->setAppValueString('jwt_secret_key', $jwtSecretKey); } + + public function getWhiteboardEnableStatistics(): bool { + return $this->appConfig->getAppValueBool('enable_statistics'); + } + + public function setWhiteboardEnableStatistics(bool $enableStatistics): void { + $this->appConfig->setAppValueBool('enable_statistics', $enableStatistics); + } + + public function getCollabBackendMetricsToken(): string { + if (!method_exists($this->appConfig, 'getAppValueString')) { + return $this->appConfig->getAppValue('collabBackendMetricsToken'); + } + + return $this->appConfig->getAppValueString('collabBackendMetricsToken'); + } + + public function setCollabBackendMetricsToken(string $collabBackendMetricsToken): void { + if (!method_exists($this->appConfig, 'setAppValueString')) { + $this->appConfig->setAppValue('collabBackendMetricsToken', $collabBackendMetricsToken); + return; + } + + $this->appConfig->setAppValueString('collabBackendMetricsToken', $collabBackendMetricsToken); + } + + public function getStatisticsDataLifetime(): int { + return $this->appConfig->getAppValueInt('statistics_data_lifetime'); + } + + public function setStatisticsDataLifetime(int $statisticsDataExpiration): void { + $this->appConfig->setAppValueInt('statistics_data_lifetime', $statisticsDataExpiration); + } } diff --git a/lib/Service/StatsService.php b/lib/Service/StatsService.php new file mode 100644 index 0000000..597e89d --- /dev/null +++ b/lib/Service/StatsService.php @@ -0,0 +1,56 @@ +connection->getQueryBuilder(); + $queryBuilder->insert('whiteboard_events') + ->values([ + 'user' => $queryBuilder->createNamedParameter($data['user']), + 'type' => $queryBuilder->createNamedParameter($data['type']), + 'share_token' => $queryBuilder->createNamedParameter($data['share_token']), + 'fileid' => $queryBuilder->createNamedParameter($data['fileid']), + 'elements' => $queryBuilder->createNamedParameter($data['elements'] ?? null), + 'size' => $queryBuilder->createNamedParameter($data['size']), + 'timestamp' => $queryBuilder->createNamedParameter($data['timestamp']), + ]) + ->executeStatement(); + } + + public function insertActiveUsersCount(int $count): void { + $queryBuilder = $this->connection->getQueryBuilder(); + $queryBuilder->insert('whiteboard_active_users') + ->values([ + 'total_users' => $queryBuilder->createNamedParameter($count), + 'timestamp' => $queryBuilder->createNamedParameter(time()), + ]) + ->executeStatement(); + } + + public function pruneData(int $beforeTime): void { + $queryBuilder = $this->connection->getQueryBuilder(); + $queryBuilder->delete('whiteboard_events') + ->where($queryBuilder->expr()->lt('timestamp', $queryBuilder->createNamedParameter($beforeTime))) + ->executeStatement(); + + $queryBuilder = $this->connection->getQueryBuilder(); + $queryBuilder->delete('whiteboard_active_users') + ->where($queryBuilder->expr()->lt('timestamp', $queryBuilder->createNamedParameter($beforeTime))) + ->executeStatement(); + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 0029054..b1cc315 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -25,6 +25,9 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('url', $this->configService->getCollabBackendUrl()); $this->initialState->provideInitialState('secret', $this->configService->getWhiteboardSharedSecret()); $this->initialState->provideInitialState('jwt', $this->jwtService->generateJWTFromPayload([])); + $this->initialState->provideInitialState('enable_statistics', $this->configService->getWhiteboardEnableStatistics()); + $this->initialState->provideInitialState('metrics_token', $this->configService->getCollabBackendMetricsToken()); + $this->initialState->provideInitialState('statistics_data_lifetime', $this->configService->getStatisticsDataLifetime()); $response = new TemplateResponse( 'whiteboard', 'admin', diff --git a/psalm.xml b/psalm.xml index 0e9a514..f2578d7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -20,5 +20,17 @@ + + + + + + + + + + + + diff --git a/src/settings/Settings.vue b/src/settings/Settings.vue index d3497ac..0137318 100644 --- a/src/settings/Settings.vue +++ b/src/settings/Settings.vue @@ -6,6 +6,9 @@ {{ t('whiteboard', 'Whiteboard settings') }} + + {{ t('whiteboard', 'Saved.') }} + {{ t('whiteboard', 'Whiteboard backend server is configured and connected.') }} @@ -33,6 +36,19 @@ + + + {{ t('whiteboard', 'Enable statistics') }} + + + + + + + +
+ + {{ t('whiteboard', 'Enable statistics') }} + +
+ +