From fcfbcc63b4ac0a304202ad1451d82b591d282c90 Mon Sep 17 00:00:00 2001 From: chandi Langecker Date: Thu, 4 Aug 2022 14:42:27 +0200 Subject: [PATCH] basic notify_push usage with session handling (rebased) Signed-off-by: chandi Langecker --- appinfo/routes.php | 5 + lib/AppInfo/Application.php | 34 ++++++ lib/Controller/SessionController.php | 88 ++++++++++++++ lib/Db/Board.php | 2 + lib/Db/Session.php | 48 ++++++++ lib/Db/SessionMapper.php | 62 ++++++++++ lib/Event/SessionClosedEvent.php | 45 +++++++ lib/Event/SessionCreatedEvent.php | 46 +++++++ .../Version10900Date202206151724222.php | 65 ++++++++++ lib/Service/BoardService.php | 14 +++ lib/Service/SessionService.php | 95 +++++++++++++++ package.json | 1 + psalm.xml | 1 + src/components/Controls.vue | 8 ++ src/components/SessionList.vue | 115 ++++++++++++++++++ src/components/board/Board.vue | 68 ++++++++++- src/listeners.js | 41 +++++++ src/main.js | 1 + src/services/SessionApi.js | 45 +++++++ tests/unit/Db/BoardTest.php | 4 + tests/unit/Service/BoardServiceTest.php | 18 +++ 21 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/SessionController.php create mode 100644 lib/Db/Session.php create mode 100644 lib/Db/SessionMapper.php create mode 100644 lib/Event/SessionClosedEvent.php create mode 100644 lib/Event/SessionCreatedEvent.php create mode 100644 lib/Migration/Version10900Date202206151724222.php create mode 100644 lib/Service/SessionService.php create mode 100644 src/components/SessionList.vue create mode 100644 src/listeners.js create mode 100644 src/services/SessionApi.js diff --git a/appinfo/routes.php b/appinfo/routes.php index 41f398e11..81fd201a2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -82,6 +82,11 @@ return [ ['name' => 'label#update', 'url' => '/labels/{labelId}', 'verb' => 'PUT'], ['name' => 'label#delete', 'url' => '/labels/{labelId}', 'verb' => 'DELETE'], + // sessions + ['name' => 'Session#create', 'url' => '/session/create', 'verb' => 'PUT'], + ['name' => 'Session#sync', 'url' => '/session/sync', 'verb' => 'POST'], + ['name' => 'Session#close', 'url' => '/session/close', 'verb' => 'POST'], + // api ['name' => 'board_api#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], ['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1f9736192..e15e111ce 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -40,6 +40,8 @@ use OCA\Deck\Event\AclUpdatedEvent; use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; +use OCA\Deck\Event\SessionClosedEvent; +use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Listeners\BeforeTemplateRenderedListener; use OCA\Deck\Listeners\ParticipantCleanupListener; use OCA\Deck\Listeners\FullTextSearchEventListener; @@ -51,8 +53,10 @@ use OCA\Deck\Reference\CardReferenceProvider; use OCA\Deck\Search\CardCommentProvider; use OCA\Deck\Search\DeckProvider; use OCA\Deck\Service\PermissionService; +use OCA\Deck\Service\SessionService; use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\Sharing\Listener; +use OCA\NotifyPush\Queue\IQueue; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -96,6 +100,7 @@ class Application extends App implements IBootstrap { $context->injectFn(Closure::fromCallable([$this, 'registerCommentsEventHandler'])); $context->injectFn(Closure::fromCallable([$this, 'registerNotifications'])); $context->injectFn(Closure::fromCallable([$this, 'registerCollaborationResources'])); + $context->injectFn(Closure::fromCallable([$this, 'registerSessionListener'])); $context->injectFn(function (IManager $shareManager) { $shareManager->registerShareProvider(DeckShareProvider::class); @@ -188,4 +193,33 @@ class Application extends App implements IBootstrap { Util::addScript('deck', 'deck-collections'); }); } + + protected function registerSessionListener(IEventDispatcher $eventDispatcher): void { + $container = $this->getContainer(); + + try { + $queue = $container->get(IQueue::class); + } catch (\Exception $e) { + // most likely notify_push is not installed. + return; + } + + // if SessionService is injected via function parameters, tests throw following error: + // "OCA\Deck\NoPermissionException: Creating boards has been disabled for your account." + // doing it this way it somehow works + $sessionService = $container->get(SessionService::class); + + + $eventDispatcher->addListener(SessionCreatedEvent::class, function (SessionCreatedEvent $event) use ($sessionService, $queue) { + $sessionService->notifyAllSessions($queue, $event->getBoardId(), "DeckBoardUpdate", $event->getUserId(), [ + "id" => $event->getBoardId() + ]); + }); + + $eventDispatcher->addListener(SessionClosedEvent::class, function (SessionClosedEvent $event) use ($sessionService, $queue) { + $sessionService->notifyAllSessions($queue, $event->getBoardId(), "DeckBoardUpdate", $event->getUserId(), [ + "id" => $event->getBoardId() + ]); + }); + } } diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php new file mode 100644 index 000000000..784596b2b --- /dev/null +++ b/lib/Controller/SessionController.php @@ -0,0 +1,88 @@ +. + * + */ + +namespace OCA\Deck\Controller; + +use OCA\Deck\Service\SessionService; +use OCA\Deck\Service\PermissionService; +use OCA\Deck\Db\BoardMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\ApiController; +use OCP\IRequest; +use OCA\Deck\Db\Acl; + +class SessionController extends ApiController { + private SessionService $sessionService; + private PermissionService $permissionService; + private BoardMapper $boardMapper; + + public function __construct($appName, + IRequest $request, + SessionService $sessionService, + PermissionService $permissionService, + BoardMapper $boardMapper + ) { + parent::__construct($appName, $request); + $this->sessionService = $sessionService; + $this->permissionService = $permissionService; + $this->boardMapper = $boardMapper; + } + + /** + * @NoAdminRequired + */ + public function create(int $boardId): DataResponse { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + + $session = $this->sessionService->initSession($boardId); + return new DataResponse([ + 'token' => $session->getToken(), + ]); + } + + /** + * notifies the server that the session is still active + * @NoAdminRequired + * @param $boardId + */ + public function sync($boardId, $token): DataResponse { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + try { + $this->sessionService->syncSession($boardId, $token); + return new DataResponse([]); + } catch (DoesNotExistException $e) { + return new DataResponse([], 403); + } + } + + /** + * delete a session if existing + * @NoAdminRequired + * @param $boardId + * @return bool + */ + public function close($boardId, $token) { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + $this->sessionService->closeSession((int)$boardId, $token); + return true; + } +} diff --git a/lib/Db/Board.php b/lib/Db/Board.php index 8cc845c35..c5fdb0202 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -44,6 +44,7 @@ class Board extends RelationalEntity { protected $users = []; protected $shared; protected $stacks = []; + protected $activeSessions = []; protected $deletedAt = 0; protected $lastModified = 0; @@ -59,6 +60,7 @@ class Board extends RelationalEntity { $this->addRelation('acl'); $this->addRelation('shared'); $this->addRelation('users'); + $this->addRelation('activeSessions'); $this->addRelation('permissions'); $this->addRelation('stacks'); $this->addRelation('settings'); diff --git a/lib/Db/Session.php b/lib/Db/Session.php new file mode 100644 index 000000000..8533777cb --- /dev/null +++ b/lib/Db/Session.php @@ -0,0 +1,48 @@ +. + * + */ + +namespace OCA\Deck\Db; + +use OCP\AppFramework\Db\Entity; + +class Session extends Entity implements \JsonSerializable { + public $id; + protected $userId; + protected $token; + protected $lastContact; + protected $boardId; + + public function __construct() { + $this->addType('id', 'integer'); + $this->addType('boardId', 'integer'); + $this->addType('lastContact', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'userId' => $this->userId, + 'token' => $this->token, + 'lastContact' => $this->lastContact, + 'boardId' => $this->boardId, + ]; + } +} diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php new file mode 100644 index 000000000..7a31138ed --- /dev/null +++ b/lib/Db/SessionMapper.php @@ -0,0 +1,62 @@ +. + * + */ + +namespace OCA\Deck\Db; + +use OCA\Deck\Service\SessionService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +class SessionMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'deck_sessions', Session::class); + } + + public function find(int $boardId, string $userId, string $token): Session { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('Session is invalid'); + } + return Session::fromRow($data); + } + + public function findAllActive($boardId) { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'board_id', 'last_contact', 'user_id') + ->from($this->getTableName()) + ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId))) + ->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME))) + ->executeQuery(); + + return $this->findEntities($qb); + } +} diff --git a/lib/Event/SessionClosedEvent.php b/lib/Event/SessionClosedEvent.php new file mode 100644 index 000000000..f5b164d5d --- /dev/null +++ b/lib/Event/SessionClosedEvent.php @@ -0,0 +1,45 @@ +. + * + */ +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCP\EventDispatcher\Event; + +class SessionClosedEvent extends Event { + private $boardId; + private $userId; + + public function __construct($boardId, $userId) { + parent::__construct(); + + $this->boardId = $boardId; + $this->userId = $userId; + } + + public function getBoardId(): int { + return $this->boardId; + } + public function getUserId(): string { + return $this->userId; + } +} diff --git a/lib/Event/SessionCreatedEvent.php b/lib/Event/SessionCreatedEvent.php new file mode 100644 index 000000000..8182f1e74 --- /dev/null +++ b/lib/Event/SessionCreatedEvent.php @@ -0,0 +1,46 @@ +. + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCP\EventDispatcher\Event; + +class SessionCreatedEvent extends Event { + private $boardId; + private $userId; + + public function __construct($boardId, $userId) { + parent::__construct(); + + $this->boardId = $boardId; + $this->userId = $userId; + } + + public function getBoardId(): int { + return $this->boardId; + } + public function getUserId(): string { + return $this->userId; + } +} diff --git a/lib/Migration/Version10900Date202206151724222.php b/lib/Migration/Version10900Date202206151724222.php new file mode 100644 index 000000000..58fddf162 --- /dev/null +++ b/lib/Migration/Version10900Date202206151724222.php @@ -0,0 +1,65 @@ +. + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version10900Date202206151724222 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + if (!$schema->hasTable('deck_sessions')) { + $table = $schema->createTable('deck_sessions'); + $table->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', 'string', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('board_id', 'integer', [ + 'notnull' => false, + ]); + $table->addColumn('token', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('last_contact', 'integer', [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['token'], 'rd_session_token_idx'); + $table->addIndex(['last_contact'], 'ts_lastcontact'); + } + return $schema; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 3c5f76cc7..63ca79c26 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -34,6 +34,8 @@ use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\ChangeHelper; use OCA\Deck\Db\IPermissionMapper; use OCA\Deck\Db\Label; +use OCA\Deck\Db\Session; +use OCA\Deck\Db\SessionMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; use OCA\Deck\Event\AclCreatedEvent; @@ -81,6 +83,7 @@ class BoardService { private IURLGenerator $urlGenerator; private IDBConnection $connection; private BoardServiceValidator $boardServiceValidator; + private SessionMapper $sessionMapper; public function __construct( BoardMapper $boardMapper, @@ -101,6 +104,7 @@ class BoardService { IURLGenerator $urlGenerator, IDBConnection $connection, BoardServiceValidator $boardServiceValidator, + SessionMapper $sessionMapper, ?string $userId ) { $this->boardMapper = $boardMapper; @@ -122,6 +126,7 @@ class BoardService { $this->urlGenerator = $urlGenerator; $this->connection = $connection; $this->boardServiceValidator = $boardServiceValidator; + $this->sessionMapper = $sessionMapper; } /** @@ -214,6 +219,7 @@ class BoardService { ]); $this->enrichWithUsers($board); $this->enrichWithBoardSettings($board); + $this->enrichWithActiveSessions($board); $this->boardsCache[$board->getId()] = $board; return $board; } @@ -448,6 +454,14 @@ class BoardService { $board->setSettings($settings); } + public function enrichWithActiveSessions(Board $board) { + $sessions = $this->sessionMapper->findAllActive($board->getId()); + + $board->setActiveSessions(array_unique(array_map(function (Session $session) { + return $session->getUserId(); + }, $sessions))); + } + /** * @param $boardId * @param $type diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php new file mode 100644 index 000000000..28f0fc91e --- /dev/null +++ b/lib/Service/SessionService.php @@ -0,0 +1,95 @@ +. + * + */ + +namespace OCA\Deck\Service; + +use OCA\Deck\Db\Session; +use OCA\Deck\Db\SessionMapper; +use OCA\Deck\Event\SessionCreatedEvent; +use OCA\Deck\Event\SessionClosedEvent; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\NotifyPush\Queue\IQueue; +use OCP\Security\ISecureRandom; + +class SessionService { + public const SESSION_VALID_TIME = 30; + + private SessionMapper $sessionMapper; + private ITimeFactory $timeFactory; + private string|null $userId; + private IEventDispatcher $eventDispatcher; + private ISecureRandom $secureRandom; + + public function __construct( + SessionMapper $sessionMapper, + ISecureRandom $secureRandom, + ITimeFactory $timeFactory, + $userId, + IEventDispatcher $eventDispatcher + ) { + $this->sessionMapper = $sessionMapper; + $this->secureRandom = $secureRandom; + $this->timeFactory = $timeFactory; + $this->userId = $userId; + $this->eventDispatcher = $eventDispatcher; + } + + public function initSession($boardId): Session { + $session = new Session(); + $session->setBoardId($boardId); + $session->setUserId($this->userId); + $session->setToken($this->secureRandom->generate(64)); + $session->setLastContact($this->timeFactory->getTime()); + + $session = $this->sessionMapper->insert($session); + $this->eventDispatcher->dispatchTyped(new SessionCreatedEvent($boardId, $this->userId)); + return $session; + } + + public function syncSession(int $boardId, string $token) { + $session = $this->sessionMapper->find($boardId, $this->userId, $token); + $session->setLastContact($this->timeFactory->getTime()); + $this->sessionMapper->update($session); + } + + public function closeSession(int $boardId, string $token): void { + try { + $session = $this->sessionMapper->find($boardId, $this->userId, $token); + $this->sessionMapper->delete($session); + } catch (DoesNotExistException $e) { + } + $this->eventDispatcher->dispatchTyped(new SessionClosedEvent($boardId, $this->userId)); + } + + public function notifyAllSessions(IQueue $queue, int $boardId, $event, $excludeUserId, $body) { + $activeSessions = $this->sessionMapper->findAllActive($boardId); + + foreach ($activeSessions as $session) { + $queue->push("notify_custom", [ + 'user' => $session->getUserId(), + 'message' => $event, + 'body' => $body + ]); + } + } +} diff --git a/package.json b/package.json index f45c01dea..4639e0c35 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^1.6.0", "@nextcloud/moment": "^1.2.1", + "@nextcloud/notify_push": "^1.1.2", "@nextcloud/router": "^2.0.1", "@nextcloud/vue": "^7.3.0", "@nextcloud/vue-dashboard": "^2.0.1", diff --git a/psalm.xml b/psalm.xml index 178ff30fd..5e2ca45de 100644 --- a/psalm.xml +++ b/psalm.xml @@ -42,6 +42,7 @@ + diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 12c482bea..e1f55daba 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -40,6 +40,8 @@

+