diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9da538a88..239ce14a1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -5,20 +5,31 @@ use Exception; use OCA\Analytics\Datasource\DatasourceEvent; use OCA\Tables\Capabilities; +use OCA\Tables\Event\RowDeletedEvent; +use OCA\Tables\Event\TableDeletedEvent; +use OCA\Tables\Event\TableOwnershipTransferredEvent; +use OCA\Tables\Event\ViewDeletedEvent; use OCA\Tables\Listener\AnalyticsDatasourceListener; use OCA\Tables\Listener\BeforeTemplateRenderedListener; use OCA\Tables\Listener\LoadAdditionalListener; use OCA\Tables\Listener\TablesReferenceListener; use OCA\Tables\Listener\UserDeletedListener; +use OCA\Tables\Listener\WhenRowDeletedAuditLogListener; +use OCA\Tables\Listener\WhenTableDeletedAuditLogListener; +use OCA\Tables\Listener\WhenTableTransferredAuditLogListener; +use OCA\Tables\Listener\WhenViewDeletedAuditLogListener; use OCA\Tables\Middleware\PermissionMiddleware; use OCA\Tables\Reference\ContentReferenceProvider; use OCA\Tables\Reference\ReferenceProvider; use OCA\Tables\Search\SearchTablesProvider; +use OCA\Tables\Service\Support\AuditLogServiceInterface; +use OCA\Tables\Service\Support\DefaultAuditLogService; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\AppFramework\IAppContainer; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; use OCP\User\Events\BeforeUserDeletedEvent; @@ -54,11 +65,17 @@ public function register(IRegistrationContext $context): void { throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); } + $context->registerService(AuditLogServiceInterface::class, fn (IAppContainer $c) => $c->query(DefaultAuditLogService::class)); + $context->registerEventListener(BeforeUserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class); $context->registerEventListener(RenderReferenceEvent::class, TablesReferenceListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); + $context->registerEventListener(TableDeletedEvent::class, WhenTableDeletedAuditLogListener::class); + $context->registerEventListener(ViewDeletedEvent::class, WhenViewDeletedAuditLogListener::class); + $context->registerEventListener(RowDeletedEvent::class, WhenRowDeletedAuditLogListener::class); + $context->registerEventListener(TableOwnershipTransferredEvent::class, WhenTableTransferredAuditLogListener::class); $context->registerSearchProvider(SearchTablesProvider::class); diff --git a/lib/Db/LegacyRowMapper.php b/lib/Db/LegacyRowMapper.php index c3a4a5db2..ae7b3996b 100644 --- a/lib/Db/LegacyRowMapper.php +++ b/lib/Db/LegacyRowMapper.php @@ -116,7 +116,12 @@ private function getInnerFilterExpressions($qb, $filterGroup, int $groupIndex): return $innerFilterExpressions; } - private function getFilterGroups($qb, $filters): array { + /** + * @param (float|int|string)[][][] $filters + * + * @psalm-param non-empty-list> $filters + */ + private function getFilterGroups(IQueryBuilder $qb, array $filters): array { $filterGroups = []; foreach ($filters as $groupIndex => $filterGroup) { $filterGroups[] = $qb->expr()->andX(...$this->getInnerFilterExpressions($qb, $filterGroup, $groupIndex)); @@ -149,7 +154,12 @@ private function resolveSearchValue(string $unresolvedSearchValue, string $userI } } - private function addOrderByRules(IQueryBuilder $qb, $sortArray) { + /** + * @param (int|string)[][] $sortArray + * + * @psalm-param list $sortArray + */ + private function addOrderByRules(IQueryBuilder $qb, array $sortArray) { foreach ($sortArray as $index => $sortRule) { $sortMode = $sortRule['mode']; if (!in_array($sortMode, ['ASC', 'DESC'])) { diff --git a/lib/Db/RowCellSuper.php b/lib/Db/RowCellSuper.php index 87a232b3e..e2d5962ec 100644 --- a/lib/Db/RowCellSuper.php +++ b/lib/Db/RowCellSuper.php @@ -36,7 +36,10 @@ public function __construct() { $this->addType('rowId', 'integer'); } - public function jsonSerializePreparation($value): array { + /** + * @param float|null|string $value + */ + public function jsonSerializePreparation(string|float|null $value): array { return [ 'id' => $this->id, 'columnId' => $this->columnId, diff --git a/lib/Event/RowDeletedEvent.php b/lib/Event/RowDeletedEvent.php new file mode 100644 index 000000000..afcfaa715 --- /dev/null +++ b/lib/Event/RowDeletedEvent.php @@ -0,0 +1,18 @@ +row; + } +} diff --git a/lib/Event/TableDeletedEvent.php b/lib/Event/TableDeletedEvent.php new file mode 100644 index 000000000..b40ed1372 --- /dev/null +++ b/lib/Event/TableDeletedEvent.php @@ -0,0 +1,18 @@ +table; + } +} diff --git a/lib/Event/TableOwnershipTransferredEvent.php b/lib/Event/TableOwnershipTransferredEvent.php new file mode 100644 index 000000000..182b65541 --- /dev/null +++ b/lib/Event/TableOwnershipTransferredEvent.php @@ -0,0 +1,26 @@ +table; + } + + public function getFromUserId(): string|null { + return $this->fromUserId; + } + + public function getToUserId(): string { + return $this->toUserId; + } +} diff --git a/lib/Event/ViewDeletedEvent.php b/lib/Event/ViewDeletedEvent.php new file mode 100644 index 000000000..563370a19 --- /dev/null +++ b/lib/Event/ViewDeletedEvent.php @@ -0,0 +1,18 @@ +view; + } +} diff --git a/lib/Listener/WhenRowDeletedAuditLogListener.php b/lib/Listener/WhenRowDeletedAuditLogListener.php new file mode 100644 index 000000000..b9ca7bde5 --- /dev/null +++ b/lib/Listener/WhenRowDeletedAuditLogListener.php @@ -0,0 +1,31 @@ + + */ +final class WhenRowDeletedAuditLogListener implements IEventListener { + public function __construct(protected AuditLogServiceInterface $auditLogService) { + } + + public function handle(Event $event): void { + if (!($event instanceof RowDeletedEvent)) { + return; + } + + $row = $event->getRow(); + $rowId = $row->getId(); + + $this->auditLogService->log("Row with ID: $rowId was deleted", [ + 'row' => $row->jsonSerialize(), + ]); + } +} diff --git a/lib/Listener/WhenTableDeletedAuditLogListener.php b/lib/Listener/WhenTableDeletedAuditLogListener.php new file mode 100644 index 000000000..aa3a1a548 --- /dev/null +++ b/lib/Listener/WhenTableDeletedAuditLogListener.php @@ -0,0 +1,30 @@ + + */ +final class WhenTableDeletedAuditLogListener implements IEventListener { + public function __construct(protected AuditLogServiceInterface $auditLogService) { + } + + public function handle(Event $event): void { + if (!($event instanceof TableDeletedEvent)) { + return; + } + + $table = $event->getTable(); + + $this->auditLogService->log("Table with ID: $table->id was deleted", [ + 'table' => $table->jsonSerialize(), + ]); + } +} diff --git a/lib/Listener/WhenTableTransferredAuditLogListener.php b/lib/Listener/WhenTableTransferredAuditLogListener.php new file mode 100644 index 000000000..b4b90ff98 --- /dev/null +++ b/lib/Listener/WhenTableTransferredAuditLogListener.php @@ -0,0 +1,34 @@ + + */ +final class WhenTableTransferredAuditLogListener implements IEventListener { + public function __construct(protected AuditLogServiceInterface $auditLogService) { + } + + public function handle(Event $event): void { + if (!($event instanceof TableOwnershipTransferredEvent)) { + return; + } + + $table = $event->getTable(); + $fromUserId = $event->getFromUserId(); + $toUserId = $event->getToUserId(); + + $this->auditLogService->log("Table with ID: $table->id was transferred from user with ID: $fromUserId to user with ID: $toUserId", [ + 'table' => $table->jsonSerialize(), + 'fromUserId' => $fromUserId, + 'toUserId' => $toUserId, + ]); + } +} diff --git a/lib/Listener/WhenViewDeletedAuditLogListener.php b/lib/Listener/WhenViewDeletedAuditLogListener.php new file mode 100644 index 000000000..755cf07d7 --- /dev/null +++ b/lib/Listener/WhenViewDeletedAuditLogListener.php @@ -0,0 +1,30 @@ + + */ +final class WhenViewDeletedAuditLogListener implements IEventListener { + public function __construct(protected AuditLogServiceInterface $auditLogService) { + } + + public function handle(Event $event): void { + if (!($event instanceof ViewDeletedEvent)) { + return; + } + + $view = $event->getView(); + + $this->auditLogService->log("View with ID: $view->id was deleted", [ + 'view' => $view->jsonSerialize(), + ]); + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index e8d3ca416..fda2ed6bb 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -13,11 +13,13 @@ use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Event\RowDeletedEvent; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -33,13 +35,24 @@ class RowService extends SuperService { private Row2Mapper $row2Mapper; private array $tmpRows = []; // holds already loaded rows as a small cache - public function __construct(PermissionsService $permissionsService, LoggerInterface $logger, ?string $userId, - ColumnMapper $columnMapper, ViewMapper $viewMapper, TableMapper $tableMapper, Row2Mapper $row2Mapper) { + protected IEventDispatcher $eventDispatcher; + + public function __construct( + PermissionsService $permissionsService, + LoggerInterface $logger, + ?string $userId, + ColumnMapper $columnMapper, + ViewMapper $viewMapper, + TableMapper $tableMapper, + Row2Mapper $row2Mapper, + IEventDispatcher $eventDispatcher + ) { parent::__construct($logger, $userId, $permissionsService); $this->columnMapper = $columnMapper; $this->viewMapper = $viewMapper; $this->tableMapper = $tableMapper; $this->row2Mapper = $row2Mapper; + $this->eventDispatcher = $eventDispatcher; } /** @@ -450,7 +463,13 @@ public function delete(int $id, ?int $viewId, string $userId): Row2 { } try { - return $this->filterRowResult($view ?? null, $this->row2Mapper->delete($item)); + $deletedRow = $this->row2Mapper->delete($item); + + $event = new RowDeletedEvent(row: $item); + + $this->eventDispatcher->dispatchTyped($event); + + return $this->filterRowResult($view ?? null, $deletedRow); } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); diff --git a/lib/Service/Support/AuditLogServiceInterface.php b/lib/Service/Support/AuditLogServiceInterface.php new file mode 100644 index 000000000..93eeaa962 --- /dev/null +++ b/lib/Service/Support/AuditLogServiceInterface.php @@ -0,0 +1,9 @@ +eventDispatcher->dispatchTyped($auditEvent); + } +} diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index 03659e594..eda2eaa19 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -5,19 +5,20 @@ namespace OCA\Tables\Service; use DateTime; - use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Event\TableDeletedEvent; +use OCA\Tables\Event\TableOwnershipTransferredEvent; use OCA\Tables\Helper\UserHelper; - use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception as OcpDbException; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IL10N; use Psr\Log\LoggerInterface; @@ -44,6 +45,8 @@ class TableService extends SuperService { protected IL10N $l; + protected IEventDispatcher $eventDispatcher; + public function __construct( PermissionsService $permissionsService, LoggerInterface $logger, @@ -56,7 +59,8 @@ public function __construct( ShareService $shareService, UserHelper $userHelper, FavoritesService $favoritesService, - IL10N $l + IL10N $l, + IEventDispatcher $eventDispatcher ) { parent::__construct($logger, $userId, $permissionsService); $this->mapper = $mapper; @@ -68,6 +72,7 @@ public function __construct( $this->userHelper = $userHelper; $this->favoritesService = $favoritesService; $this->l = $l; + $this->eventDispatcher = $eventDispatcher; } /** @@ -349,6 +354,14 @@ public function setOwner(int $id, string $newOwnerUserId, ?string $userId = null throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } + $event = new TableOwnershipTransferredEvent( + table: $table, + toUserId: $newOwnerUserId, + fromUserId: $userId + ); + + $this->eventDispatcher->dispatchTyped($event); + return $table; } @@ -423,6 +436,11 @@ public function delete(int $id, ?string $userId = null): Table { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); } + + $event = new TableDeletedEvent(table: $item); + + $this->eventDispatcher->dispatchTyped($event); + return $item; } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index d509fa85c..83782fd49 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -16,10 +16,12 @@ use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Event\ViewDeletedEvent; use OCA\Tables\Helper\UserHelper; use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IL10N; use Psr\Log\LoggerInterface; @@ -39,6 +41,8 @@ class ViewService extends SuperService { protected IL10N $l; + protected IEventDispatcher $eventDispatcher; + public function __construct( PermissionsService $permissionsService, LoggerInterface $logger, @@ -48,7 +52,8 @@ public function __construct( RowService $rowService, UserHelper $userHelper, FavoritesService $favoritesService, - IL10N $l + IL10N $l, + IEventDispatcher $eventDispatcher ) { parent::__construct($logger, $userId, $permissionsService); $this->l = $l; @@ -57,6 +62,7 @@ public function __construct( $this->rowService = $rowService; $this->userHelper = $userHelper; $this->favoritesService = $favoritesService; + $this->eventDispatcher = $eventDispatcher; } @@ -274,7 +280,13 @@ public function delete(int $id, ?string $userId = null): View { $this->shareService->deleteAllForView($view); try { - return $this->mapper->delete($view); + $deletedView = $this->mapper->delete($view); + + $event = new ViewDeletedEvent(view: $view); + + $this->eventDispatcher->dispatchTyped($event); + + return $deletedView; } catch (\OCP\DB\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': '.$e->getMessage()); @@ -301,6 +313,11 @@ public function deleteByObject(View $view, ?string $userId = null): View { $this->shareService->deleteAllForView($view); $this->mapper->delete($view); + + $event = new ViewDeletedEvent(view: $view); + + $this->eventDispatcher->dispatchTyped($event); + return $view; } catch (Exception $e) { $this->logger->error($e->getMessage()); diff --git a/tests/unit/Listener/WhenRowDeletedAuditLogTest.php b/tests/unit/Listener/WhenRowDeletedAuditLogTest.php new file mode 100644 index 000000000..7afa7726a --- /dev/null +++ b/tests/unit/Listener/WhenRowDeletedAuditLogTest.php @@ -0,0 +1,40 @@ +auditLogService = $this->createMock(AuditLogServiceInterface::class); + + $this->listener = new WhenRowDeletedAuditLogListener($this->auditLogService); + } + + public function testHandle(): void { + $row = new Row2(); + $row->setId(1); + + $event = new RowDeletedEvent(row: $row); + + $this->auditLogService + ->expects($this->once()) + ->method('log') + ->with( + $this->equalTo("Row with ID: {$row->getId()} was deleted"), + $this->equalTo([ + 'row' => $row->jsonSerialize(), + ]) + ); + + $this->listener->handle($event); + } +} diff --git a/tests/unit/Listener/WhenTableDeletedAuditLogTest.php b/tests/unit/Listener/WhenTableDeletedAuditLogTest.php new file mode 100644 index 000000000..643a0d10e --- /dev/null +++ b/tests/unit/Listener/WhenTableDeletedAuditLogTest.php @@ -0,0 +1,40 @@ +auditLogService = $this->createMock(AuditLogServiceInterface::class); + + $this->listener = new WhenTableDeletedAuditLogListener($this->auditLogService); + } + + public function testHandle(): void { + $table = new Table(); + $table->id = 1; + + $event = new TableDeletedEvent($table); + + $this->auditLogService + ->expects($this->once()) + ->method('log') + ->with( + $this->equalTo("Table with ID: {$table->id} was deleted"), + $this->equalTo([ + 'table' => $table->jsonSerialize(), + ]) + ); + + $this->listener->handle($event); + } +} diff --git a/tests/unit/Listener/WhenTableOwnerTransferredAuditLogTest.php b/tests/unit/Listener/WhenTableOwnerTransferredAuditLogTest.php new file mode 100644 index 000000000..ae9f956e1 --- /dev/null +++ b/tests/unit/Listener/WhenTableOwnerTransferredAuditLogTest.php @@ -0,0 +1,44 @@ +auditLogService = $this->createMock(AuditLogServiceInterface::class); + + $this->listener = new WhenTableTransferredAuditLogListener($this->auditLogService); + } + + public function testHandle(): void { + $table = new Table(); + $table->id = 1; + $fromUserId = 'user1'; + $toUserId = 'user2'; + + $event = new TableOwnershipTransferredEvent($table, $toUserId, $fromUserId); + + $this->auditLogService + ->expects($this->once()) + ->method('log') + ->with( + $this->equalTo("Table with ID: {$table->id} was transferred from user with ID: $fromUserId to user with ID: $toUserId"), + $this->equalTo([ + 'table' => $table->jsonSerialize(), + 'fromUserId' => $fromUserId, + 'toUserId' => $toUserId, + ]) + ); + + $this->listener->handle($event); + } +} diff --git a/tests/unit/Listener/WhenViewDeletedAuditLogTest.php b/tests/unit/Listener/WhenViewDeletedAuditLogTest.php new file mode 100644 index 000000000..d15be7352 --- /dev/null +++ b/tests/unit/Listener/WhenViewDeletedAuditLogTest.php @@ -0,0 +1,40 @@ +auditLogService = $this->createMock(AuditLogServiceInterface::class); + + $this->listener = new WhenViewDeletedAuditLogListener($this->auditLogService); + } + + public function testHandle(): void { + $view = new View(); + $view->id = 1; + + $event = new ViewDeletedEvent(view: $view); + + $this->auditLogService + ->expects($this->once()) + ->method('log') + ->with( + $this->equalTo("View with ID: {$view->id} was deleted"), + $this->equalTo([ + 'view' => $view->jsonSerialize(), + ]) + ); + + $this->listener->handle($event); + } +} diff --git a/tests/unit/Service/Support/DefaultAuditLogServiceTest.php b/tests/unit/Service/Support/DefaultAuditLogServiceTest.php new file mode 100644 index 000000000..152b99fe5 --- /dev/null +++ b/tests/unit/Service/Support/DefaultAuditLogServiceTest.php @@ -0,0 +1,34 @@ +eventDispatcher = $this->createMock(IEventDispatcher::class); + + $this->service = new DefaultAuditLogService($this->eventDispatcher); + } + + public function testLog(): void { + $message = 'Test message'; + $context = ['key' => 'value']; + + $this->eventDispatcher + ->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event) use ($message, $context) { + return $event->getLogMessage() === $message && $event->getParameters() === $context; + })); + + $this->service->log($message, $context); + } +}