diff --git a/appinfo/info.xml b/appinfo/info.xml
index 297db3ef1..fa5432a08 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,6 +40,7 @@
OCA\Deck\Cron\DeleteCronOCA\Deck\Cron\ScheduledNotificationsOCA\Deck\Cron\CardDescriptionActivity
+ OCA\Deck\Cron\SessionsCleanup
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 41f398e11..9105bf8ef 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -150,5 +150,10 @@ return [
['name' => 'overview_api#upcomingCards', 'url' => '/api/v{apiVersion}/overview/upcoming', 'verb' => 'GET'],
['name' => 'search#search', 'url' => '/api/v{apiVersion}/search', 'verb' => 'GET'],
+
+ // sessions
+ ['name' => 'Session#create', 'url' => '/api/v{apiVersion}/session/create', 'verb' => 'PUT'],
+ ['name' => 'Session#sync', 'url' => '/api/v{apiVersion}/session/sync', 'verb' => 'POST'],
+ ['name' => 'Session#close', 'url' => '/api/v{apiVersion}/session/close', 'verb' => 'POST'],
]
];
diff --git a/docs/API.md b/docs/API.md
index 654f78b87..5e95da1db 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1394,3 +1394,110 @@ A bad request response is returned if invalid input values are provided. The res
A not found response might be returned if:
- The card for the given cardId could not be found
- The comment could not be found
+
+
+## Sessions
+
+### PUT /session/create - creates a new session
+
+#### Request parameters
+
+| Parameter | Type | Description |
+| --------- | ------- | ---------------------------------------------------- |
+| boardId | Integer | The id of the opened board |
+
+```
+curl -X PUT 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/create' \
+ -H 'Accept: application/json' -H 'OCS-APIRequest: true' \
+ -H 'Content-Type: application/json;charset=utf-8' \
+ --data '{"boardId":1}'
+```
+
+#### Response
+
+##### 200 Success
+
+```json
+{
+ "ocs": {
+ "meta": {
+ "status": "ok",
+ "statuscode": 200,
+ "message": "OK"
+ },
+ "data": {
+ "token": "+zcJHf4rC6dobVSbuNa3delkCSfTW8OvYWTyLFvSpIv80FjtgLIj0ARlxspsazNQ"
+ }
+ }
+}
+```
+
+
+### POST /session/sync - notifies the server, that the session is still open
+
+#### Request body
+
+| Parameter | Type | Description |
+| --------- | ------- | ---------------------------------------------------- |
+| boardId | Integer | The id of the opened board |
+| token | String | The session token from the /sessions/create response |
+
+
+```
+curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/create' \
+ -H 'Accept: application/json' -H 'OCS-APIRequest: true' \
+ -H 'Content-Type: application/json;charset=utf-8' \
+ --data '{"boardId":1, "token":"X3DyyoFslArF0t0NBZXzZXzcy8feoX/OEytSNXZtPg9TpUgO5wrkJ38IW3T/FfpV"}'
+```
+
+#### Response
+
+##### 200 Success
+```json
+{
+ "ocs": {
+ "meta": {
+ "status": "ok",
+ "statuscode": 200,
+ "message": "OK"
+ },
+ "data": []
+ }
+}
+```
+
+##### 404 Not Found
+the provided token is invalid or expired
+
+
+### POST /session/close - closes the session
+
+#### Request body
+
+| Parameter | Type | Description |
+| --------- | ------- | ---------------------------------------------------- |
+| boardId | Integer | The id of the opened board |
+| token | String | The session token from the /sessions/create response |
+
+```
+curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/close' \
+ -H 'Accept: application/json' -H 'OCS-APIRequest: true' \
+ -H 'Content-Type: application/json;charset=utf-8' \
+ --data '{"boardId":1, "token":"X3DyyoFslArF0t0NBZXzZXzcy8feoX/OEytSNXZtPg9TpUgO5wrkJ38IW3T/FfpV"}'
+```
+
+#### Response
+
+##### 200 Success
+```json
+{
+ "ocs": {
+ "meta": {
+ "status": "ok",
+ "statuscode": 200,
+ "message": "OK"
+ },
+ "data": []
+ }
+}
+```
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 1f9736192..b3695e1eb 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -40,10 +40,13 @@ 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;
use OCA\Deck\Listeners\ResourceListener;
+use OCA\Deck\Listeners\LiveUpdateListener;
use OCA\Deck\Middleware\DefaultBoardMiddleware;
use OCA\Deck\Middleware\ExceptionMiddleware;
use OCA\Deck\Notification\Notifier;
@@ -147,6 +150,10 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class);
$context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class);
$context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class);
+
+ // Event listening for realtime updates via notify_push
+ $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class);
+ $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class);
}
public function registerNotifications(NotificationManager $notificationManager): void {
diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php
new file mode 100644
index 000000000..1d1744c9c
--- /dev/null
+++ b/lib/Controller/SessionController.php
@@ -0,0 +1,91 @@
+.
+ *
+ */
+
+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\OCSController;
+use OCP\IRequest;
+use OCA\Deck\Db\Acl;
+
+class SessionController extends OCSController {
+ 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(int $boardId, string $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([], 404);
+ }
+ }
+
+ /**
+ * delete a session if existing
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ * @param $boardId
+ */
+ public function close(int $boardId, string $token) {
+ $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ);
+ $this->sessionService->closeSession($boardId, $token);
+ return new DataResponse();
+ }
+}
diff --git a/lib/Cron/SessionsCleanup.php b/lib/Cron/SessionsCleanup.php
new file mode 100644
index 000000000..cbdf18713
--- /dev/null
+++ b/lib/Cron/SessionsCleanup.php
@@ -0,0 +1,56 @@
+.
+ *
+ */
+
+
+
+namespace OCA\Deck\Cron;
+
+use OCA\Deck\Service\SessionService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\ILogger;
+
+class SessionsCleanup extends TimedJob {
+ private $sessionService;
+ private $documentService;
+ private $logger;
+ private $imageService;
+
+
+ public function __construct(ITimeFactory $time,
+ SessionService $sessionService,
+ ILogger $logger) {
+ parent::__construct($time);
+ $this->sessionService = $sessionService;
+ $this->logger = $logger;
+ $this->setInterval(SessionService::SESSION_VALID_TIME);
+ }
+
+ protected function run($argument) {
+ $this->logger->debug('Run cleanup job for deck sessions');
+
+ $removedSessions = $this->sessionService->removeInactiveSessions();
+ $this->logger->debug('Removed ' . $removedSessions . ' inactive sessions');
+ }
+}
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..ae0151ddb
--- /dev/null
+++ b/lib/Db/Session.php
@@ -0,0 +1,51 @@
+.
+ *
+ */
+
+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..8d2a8f6b8
--- /dev/null
+++ b/lib/Db/SessionMapper.php
@@ -0,0 +1,74 @@
+.
+ *
+ */
+
+namespace OCA\Deck\Db;
+
+use OCA\Deck\Service\SessionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/** @template-extends QBMapper */
+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('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');
+ }
+ $session = Session::fromRow($data);
+ if ($session->getUserId() != $userId || $session->getBoardId() != $boardId) {
+ throw new DoesNotExistException('Session is invalid');
+ }
+ return $session;
+ }
+
+ public function findAllActive($boardId) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('id', 'board_id', 'last_contact', 'user_id', 'token')
+ ->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);
+ }
+ public function deleteInactive(): int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->getTableName())
+ ->where($qb->expr()->lt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME)));
+ return $qb->executeStatement();
+ }
+}
diff --git a/lib/Event/SessionClosedEvent.php b/lib/Event/SessionClosedEvent.php
new file mode 100644
index 000000000..3364ea28a
--- /dev/null
+++ b/lib/Event/SessionClosedEvent.php
@@ -0,0 +1,48 @@
+.
+ *
+ */
+declare(strict_types=1);
+
+
+namespace OCA\Deck\Event;
+
+use OCP\EventDispatcher\Event;
+
+class SessionClosedEvent extends Event {
+ private $boardId;
+ private $userId;
+
+ public function __construct(int $boardId, string $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..8c2e3c11c
--- /dev/null
+++ b/lib/Event/SessionCreatedEvent.php
@@ -0,0 +1,49 @@
+.
+ *
+ */
+
+declare(strict_types=1);
+
+
+namespace OCA\Deck\Event;
+
+use OCP\EventDispatcher\Event;
+
+class SessionCreatedEvent extends Event {
+ private $boardId;
+ private $userId;
+
+ public function __construct(int $boardId, string $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/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php
new file mode 100644
index 000000000..8138488f2
--- /dev/null
+++ b/lib/Listeners/LiveUpdateListener.php
@@ -0,0 +1,87 @@
+
+ *
+ * @author Julius Härtl
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+
+
+namespace OCA\Deck\Listeners;
+
+use OCA\Deck\NotifyPushEvents;
+use OCA\Deck\Event\SessionClosedEvent;
+use OCA\Deck\Event\SessionCreatedEvent;
+use OCA\Deck\Service\SessionService;
+use OCA\NotifyPush\Queue\IQueue;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IRequest;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/** @template-implements IEventListener */
+class LiveUpdateListener implements IEventListener {
+ private LoggerInterface $logger;
+ private SessionService $sessionService;
+ private IRequest $request;
+ private $queue;
+
+ public function __construct(
+ ContainerInterface $container,
+ IRequest $request,
+ LoggerInterface $logger,
+ SessionService $sessionService
+ ) {
+ try {
+ $this->queue = $container->get(IQueue::class);
+ } catch (\Exception $e) {
+ // most likely notify_push is not installed.
+ return;
+ }
+ $this->logger = $logger;
+ $this->sessionService = $sessionService;
+ $this->request = $request;
+ }
+
+ public function handle(Event $event): void {
+ if (!$this->queue) {
+ // notify_push is not active
+ return;
+ }
+
+ try {
+ // the web frontend is adding the Session-ID as a header on every request
+ // TODO: verify the token! this currently allows to spoof a token from someone
+ // else, preventing this person from getting any live updates
+ $causingSessionToken = $this->request->getHeader('x-nc-deck-session');
+ if (
+ $event instanceof SessionCreatedEvent ||
+ $event instanceof SessionClosedEvent
+ ) {
+ $this->sessionService->notifyAllSessions($this->queue, $event->getBoardId(), NotifyPushEvents::DeckBoardUpdate, [
+ 'id' => $event->getBoardId()
+ ], $causingSessionToken);
+ }
+ } catch (\Exception $e) {
+ $this->logger->error('Error when handling live update event', ['exception' => $e]);
+ }
+ }
+}
diff --git a/lib/Migration/Version10900Date202206151724222.php b/lib/Migration/Version10900Date202206151724222.php
new file mode 100644
index 000000000..037fe56af
--- /dev/null
+++ b/lib/Migration/Version10900Date202206151724222.php
@@ -0,0 +1,67 @@
+.
+ *
+ */
+
+declare(strict_types=1);
+
+
+namespace OCA\Deck\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+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', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('user_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ ]);
+ $table->addColumn('board_id', Types::INTEGER, [
+ 'notnull' => false,
+ ]);
+ $table->addColumn('token', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('last_contact', Types::INTEGER, [
+ 'notnull' => true,
+ 'length' => 20,
+ 'unsigned' => true,
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(['board_id'], 'deck_session_board_id_idx');
+ $table->addIndex(['token'], 'deck_session_token_idx');
+ $table->addIndex(['last_contact'], 'deck_session_last_contact_idx');
+ }
+ return $schema;
+ }
+}
diff --git a/lib/NotifyPushEvents.php b/lib/NotifyPushEvents.php
new file mode 100644
index 000000000..f12daa582
--- /dev/null
+++ b/lib/NotifyPushEvents.php
@@ -0,0 +1,29 @@
+.
+ *
+ */
+
+namespace OCA\Deck;
+
+class NotifyPushEvents {
+ public const DeckBoardUpdate = 'deck_board_update';
+}
diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php
index 3c5f76cc7..a7388f29d 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,18 @@ class BoardService {
$board->setSettings($settings);
}
+ public function enrichWithActiveSessions(Board $board) {
+ $sessions = $this->sessionMapper->findAllActive($board->getId());
+
+ $board->setActiveSessions(array_values(
+ 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..a6287a050
--- /dev/null
+++ b/lib/Service/SessionService.php
@@ -0,0 +1,127 @@
+.
+ *
+ */
+
+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 = 92;
+
+ private SessionMapper $sessionMapper;
+ private ITimeFactory $timeFactory;
+ private $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(int $boardId): Session {
+ $session = new Session();
+ $session->setBoardId($boardId);
+ $session->setUserId($this->userId);
+ $session->setToken($this->secureRandom->generate(32));
+ $session->setLastContact($this->timeFactory->getTime());
+
+ $session = $this->sessionMapper->insert($session);
+ $this->eventDispatcher->dispatchTyped(new SessionCreatedEvent($boardId, $this->userId));
+ return $session;
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ 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 removeInactiveSessions(): int {
+ return $this->sessionMapper->deleteInactive();
+ }
+
+ public function notifyAllSessions(IQueue $queue, int $boardId, $event, $body, $causingSessionToken = null) {
+ $activeSessions = $this->sessionMapper->findAllActive($boardId);
+ $userIds = [];
+ foreach ($activeSessions as $session) {
+ if ($causingSessionToken && $session->getToken() === $causingSessionToken) {
+ // Limitation:
+ // If the same user has a second session active, the session $causingSessionToken
+ // still gets notified due to the current fact, that notify_push only supports
+ // to specify users, not individual sessions
+ // https://github.com/nextcloud/notify_push/issues/195
+ continue;
+ }
+
+ // don't notify the same user multiple times
+ if (!in_array($session->getUserId(), $userIds)) {
+ $userIds[] = $session->getUserId();
+ }
+ }
+
+ if ($causingSessionToken) {
+ // we only send a slice of the session token to everyone
+ // since knowledge of the full token allows everyone
+ // to close the session maliciously
+ $body['_causingSessionToken'] = substr($causingSessionToken, 0, 12);
+ }
+ foreach ($userIds as $userId) {
+ $queue->push('notify_custom', [
+ 'user' => $userId,
+ 'message' => $event,
+ 'body' => $body
+ ]);
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 13af3235f..71178385a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^1.6.0",
"@nextcloud/moment": "^1.2.1",
+ "@nextcloud/notify_push": "^1.1.3",
"@nextcloud/router": "^2.0.1",
"@nextcloud/vue": "^7.3.0",
"@nextcloud/vue-dashboard": "^2.0.1",
@@ -3279,6 +3280,90 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/@nextcloud/notify_push": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.1.3.tgz",
+ "integrity": "sha512-dHu3mz2tcqFl43DBxRhbLRY5J8gGi/mwg9PgHbEtK9qDOZ4EFUUXDteWe+B4TCghDGh+xKS6U7oDC5txtF+JaQ==",
+ "dependencies": {
+ "@nextcloud/axios": "^1.11.0",
+ "@nextcloud/capabilities": "^1.0.4",
+ "@nextcloud/event-bus": "^3.0.2"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/auth": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-1.3.0.tgz",
+ "integrity": "sha512-GfwRM9W7hat4psNdAt74UHEV+drEXQ53klCVp6JpON66ZLPeK5eJ1LQuiQDkpUxZpqNeaumXjiB98h5cug/uQw==",
+ "dependencies": {
+ "@nextcloud/event-bus": "^1.1.3",
+ "@nextcloud/typings": "^0.2.2",
+ "core-js": "^3.6.4"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/auth/node_modules/@nextcloud/event-bus": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.3.0.tgz",
+ "integrity": "sha512-+U5MnCvfnNWvf0lvdqJg8F+Nm8wN+s9ayuBjtiEQxTAcootv7lOnlMgfreqF3l2T0Wet2uZh4JbFVUWf8l3w7g==",
+ "dependencies": {
+ "@types/semver": "^7.3.5",
+ "core-js": "^3.11.2",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-NyaiSC2GX2CPaH/MUGGMTTTza/TW9ZqWNGWq6LJ+pLER8nqZ9BQkwJ5kXUYGo+i3cka68PO+9WhcDv4fSABpuQ==",
+ "dependencies": {
+ "@nextcloud/auth": "^1.3.0",
+ "axios": "^0.27.1",
+ "core-js": "^3.6.4"
+ },
+ "engines": {
+ "node": "^16.0.0",
+ "npm": "^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/core-js": {
+ "version": "3.26.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz",
+ "integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==",
+ "hasInstallScript": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@nextcloud/notify_push/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ },
"node_modules/@nextcloud/router": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz",
@@ -20968,6 +21053,76 @@
}
}
},
+ "@nextcloud/notify_push": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.1.3.tgz",
+ "integrity": "sha512-dHu3mz2tcqFl43DBxRhbLRY5J8gGi/mwg9PgHbEtK9qDOZ4EFUUXDteWe+B4TCghDGh+xKS6U7oDC5txtF+JaQ==",
+ "requires": {
+ "@nextcloud/axios": "^1.11.0",
+ "@nextcloud/capabilities": "^1.0.4",
+ "@nextcloud/event-bus": "^3.0.2"
+ },
+ "dependencies": {
+ "@nextcloud/auth": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-1.3.0.tgz",
+ "integrity": "sha512-GfwRM9W7hat4psNdAt74UHEV+drEXQ53klCVp6JpON66ZLPeK5eJ1LQuiQDkpUxZpqNeaumXjiB98h5cug/uQw==",
+ "requires": {
+ "@nextcloud/event-bus": "^1.1.3",
+ "@nextcloud/typings": "^0.2.2",
+ "core-js": "^3.6.4"
+ },
+ "dependencies": {
+ "@nextcloud/event-bus": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.3.0.tgz",
+ "integrity": "sha512-+U5MnCvfnNWvf0lvdqJg8F+Nm8wN+s9ayuBjtiEQxTAcootv7lOnlMgfreqF3l2T0Wet2uZh4JbFVUWf8l3w7g==",
+ "requires": {
+ "@types/semver": "^7.3.5",
+ "core-js": "^3.11.2",
+ "semver": "^7.3.5"
+ }
+ }
+ }
+ },
+ "@nextcloud/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-NyaiSC2GX2CPaH/MUGGMTTTza/TW9ZqWNGWq6LJ+pLER8nqZ9BQkwJ5kXUYGo+i3cka68PO+9WhcDv4fSABpuQ==",
+ "requires": {
+ "@nextcloud/auth": "^1.3.0",
+ "axios": "^0.27.1",
+ "core-js": "^3.6.4"
+ }
+ },
+ "core-js": {
+ "version": "3.26.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz",
+ "integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw=="
+ },
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ }
+ }
+ },
"@nextcloud/router": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz",
diff --git a/package.json b/package.json
index f45c01dea..18e594456 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.3",
"@nextcloud/router": "^2.0.1",
"@nextcloud/vue": "^7.3.0",
"@nextcloud/vue-dashboard": "^2.0.1",
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index 12c482bea..0ab662036 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -40,6 +40,8 @@
+
(a.title < b.title) ? -1 : 1)
},
+ presentUsers() {
+ if (!this.board) return []
+ // get user object including displayname from the list of all users with acces
+ return this.board.users.filter((user) => this.board.activeSessions.includes(user.uid))
+ },
},
watch: {
board(current, previous) {
diff --git a/src/components/SessionList.vue b/src/components/SessionList.vue
new file mode 100644
index 000000000..5e996258d
--- /dev/null
+++ b/src/components/SessionList.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index ae5492436..b7c2473d3 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -81,6 +81,7 @@ import Stack from './Stack.vue'
import { NcEmptyContent } from '@nextcloud/vue'
import GlobalSearchResults from '../search/GlobalSearchResults.vue'
import { showError } from '../../helpers/errors.js'
+import { createSession } from '../../sessions.js'
export default {
name: 'Board',
@@ -128,14 +129,26 @@ export default {
},
},
watch: {
- id: 'fetchData',
+ id(newValue, oldValue) {
+ if (this.session) {
+ // close old session
+ this.session.close()
+ }
+ this.session = createSession(newValue)
+
+ this.fetchData()
+ },
showArchived() {
this.fetchData()
},
},
created() {
+ this.session = createSession(this.id)
this.fetchData()
},
+ beforeDestroy() {
+ this.session.close()
+ },
methods: {
async fetchData() {
this.loading = true
diff --git a/src/main.js b/src/main.js
index 2d1a28132..8f519b11e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -31,6 +31,7 @@ import { subscribe } from '@nextcloud/event-bus'
import { Tooltip } from '@nextcloud/vue'
import ClickOutside from 'vue-click-outside'
import './models/index.js'
+import './sessions.js'
// the server snap.js conflicts with vertical scrolling so we disable it
document.body.setAttribute('data-snap-ignore', 'true')
diff --git a/src/services/SessionApi.js b/src/services/SessionApi.js
new file mode 100644
index 000000000..60eebfa30
--- /dev/null
+++ b/src/services/SessionApi.js
@@ -0,0 +1,56 @@
+/*
+ * @copyright Copyright (c) 2022, chandi Langecker (git@chandi.it)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+export class SessionApi {
+
+ url(url) {
+ return generateOcsUrl(`apps/deck/api/v1.0${url}`)
+ }
+
+ async createSession(boardId) {
+ return (await axios.put(this.url('/session/create'), { boardId })).data.ocs.data
+ }
+
+ async syncSession(boardId, token) {
+ return await axios.post(this.url('/session/sync'), { boardId, token })
+ }
+
+ async closeSession(boardId, token) {
+ return await axios.post(this.url('/session/close'), { boardId, token })
+ }
+
+ async closeSessionViaBeacon(boardId, token) {
+ const body = {
+ boardId,
+ token,
+ }
+ const headers = {
+ type: 'application/json',
+ }
+ const blob = new Blob([JSON.stringify(body)], headers)
+ navigator.sendBeacon(this.url('/session/close'), blob)
+ }
+
+}
+
+export const sessionApi = new SessionApi()
diff --git a/src/sessions.js b/src/sessions.js
new file mode 100644
index 000000000..942d34084
--- /dev/null
+++ b/src/sessions.js
@@ -0,0 +1,137 @@
+/*
+ * @copyright Copyright (c) 2022, chandi Langecker (git@chandi.it)
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import { listen } from '@nextcloud/notify_push'
+import { sessionApi } from './services/SessionApi.js'
+import store from './store/main.js'
+import axios from '@nextcloud/axios'
+
+const SESSION_INTERVAL = 90 // in seconds
+
+let hasPush = false
+
+/**
+ * used to verify, whether an event is originated by ourselves
+ *
+ * @param token
+ */
+function isOurSessionToken(token) {
+ if (axios.defaults.headers['x-nc-deck-session']
+ && axios.defaults.headers['x-nc-deck-session'].startsWith(token)) {
+ return true
+ } else {
+ return false
+ }
+}
+
+hasPush = listen('deck_board_update', (name, body) => {
+ // ignore update events which we have triggered ourselves
+ if (isOurSessionToken(body._causingSessionToken)) return
+
+ // only handle update events for the currently open board
+ const currentBoardId = store.state.currentBoard?.id
+ if (body.id !== currentBoardId) return
+
+ store.dispatch('refreshBoard', currentBoardId)
+})
+
+/**
+ * is the notify_push app active and can
+ * provide us with real time updates?
+ */
+export function isNotifyPushEnabled() {
+ return hasPush
+}
+
+/**
+ *
+ * @param boardId
+ */
+export function createSession(boardId) {
+
+ if (!isNotifyPushEnabled()) {
+ // return a dummy object
+ return {
+ async close() {},
+ }
+ }
+
+ // let's try to make createSession() synchronous, so that
+ // the component doesn't need to bother about the asynchronousness
+ let tokenPromise
+ let token
+ const create = () => {
+ tokenPromise = sessionApi.createSession(boardId).then(res => res.token)
+ tokenPromise.then((t) => {
+ token = t
+ axios.defaults.headers['x-nc-deck-session'] = t
+ })
+ }
+ create()
+
+ const ensureSession = async () => {
+ if (!tokenPromise) {
+ create()
+ return
+ }
+ try {
+ await sessionApi.syncSession(boardId, await tokenPromise)
+ } catch (err) {
+ // session probably expired, let's
+ // create a fresh session
+ create()
+ }
+ }
+
+ // periodically notify the server that we are still here
+ let interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
+
+ // close session when tab gets hidden/inactive
+ const visibilitychangeListener = () => {
+ if (document.visibilityState === 'hidden') {
+ sessionApi.closeSessionViaBeacon(boardId, token)
+ tokenPromise = null
+ token = null
+ delete axios.defaults.headers['x-nc-deck-session']
+
+ // stop session refresh interval
+ clearInterval(interval)
+ } else {
+ // tab is back in focus or was restored from the bfcache
+ ensureSession()
+
+ // we must assume that the websocket connection was
+ // paused and we have missed updates in the meantime.
+ store.dispatch('refreshBoard', store.state.currentBoard?.id)
+
+ // restart session refresh interval
+ interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
+ }
+ }
+ document.addEventListener('visibilitychange', visibilitychangeListener)
+
+ return {
+ async close() {
+ clearInterval(interval)
+ document.removeEventListener('visibilitychange', visibilitychangeListener)
+ await sessionApi.closeSession(boardId, await tokenPromise)
+ },
+ }
+}
diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml
index 6ef85a656..877ead239 100644
--- a/tests/integration/config/behat.yml
+++ b/tests/integration/config/behat.yml
@@ -11,3 +11,4 @@ default:
- CommentContext
- AttachmentContext
- SearchContext
+ - SessionContext
diff --git a/tests/integration/features/acl.feature b/tests/integration/features/acl.feature
index 2937a377f..51cb1be3b 100644
--- a/tests/integration/features/acl.feature
+++ b/tests/integration/features/acl.feature
@@ -71,7 +71,7 @@ Feature: acl
Scenario: Reshare a board
Given Logging in using web as "user0"
- And creates a board named "Reshared board" with color "ff0000"
+ And creates a board named "Shared board" with color "ff0000"
And shares the board with user "user1"
| permissionEdit | 0 |
| permissionShare | 1 |
diff --git a/tests/integration/features/bootstrap/BoardContext.php b/tests/integration/features/bootstrap/BoardContext.php
index 420a7d410..a423acab4 100644
--- a/tests/integration/features/bootstrap/BoardContext.php
+++ b/tests/integration/features/bootstrap/BoardContext.php
@@ -32,6 +32,10 @@ class BoardContext implements Context {
return $this->card;
}
+ public function getLastUsedBoard() {
+ return $this->board;
+ }
+
/**
* @Given /^creates a board with example content$/
*/
@@ -57,7 +61,21 @@ class BoardContext implements Context {
* @When /^fetches the board named "([^"]*)"$/
*/
public function fetchesTheBoardNamed($boardName) {
- $this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $this->board['id'], []);
+ $id = null;
+ if (!$this->board || $boardName != $this->board['title']) {
+ $this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards', []);
+ $boards = json_decode((string)$this->getResponse()->getBody(), true);
+ foreach (array_reverse($boards) as $board) {
+ if ($board['title'] == $boardName) {
+ $id = $board['id'];
+ break;
+ }
+ }
+ Assert::assertNotNull($id, "Could not find board named ".$boardName);
+ } else {
+ $id = $this->board['id'];
+ }
+ $this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $id, []);
$this->getResponse()->getBody()->seek(0);
$this->board = json_decode((string)$this->getResponse()->getBody(), true);
}
diff --git a/tests/integration/features/bootstrap/ServerContext.php b/tests/integration/features/bootstrap/ServerContext.php
index 87b068ec8..6880f764c 100644
--- a/tests/integration/features/bootstrap/ServerContext.php
+++ b/tests/integration/features/bootstrap/ServerContext.php
@@ -47,4 +47,8 @@ class ServerContext implements Context {
public function getReqestToken(): string {
return $this->requestToken;
}
+
+ public function getCurrentUser(): string {
+ return $this->currentUser;
+ }
}
diff --git a/tests/integration/features/bootstrap/SessionContext.php b/tests/integration/features/bootstrap/SessionContext.php
new file mode 100644
index 000000000..e9703a0a1
--- /dev/null
+++ b/tests/integration/features/bootstrap/SessionContext.php
@@ -0,0 +1,80 @@
+getEnvironment();
+
+ $this->serverContext = $environment->getContext('ServerContext');
+ $this->boardContext = $environment->getContext('BoardContext');
+ }
+
+ /**
+ * @Given user opens the board named :name
+ */
+ public function opensTheBoardNamed($name) {
+ $this->boardContext->fetchesTheBoardNamed($name);
+
+ $board = $this->boardContext->getLastUsedBoard();
+ $this->requestContext->sendOCSRequest('PUT', '/apps/deck/api/v1.0/session/create', [
+ 'boardId' => $board['id'],
+ ]);
+ $res = json_decode((string)$this->getResponse()->getBody(), true);
+ Assert::assertArrayHasKey('token', $res['ocs']['data'], "session creation did not respond with a token");
+
+ // store token
+ $user = $this->serverContext->getCurrentUser();
+ $this->token[$user] = $res['ocs']['data']['token'];
+ }
+
+ /**
+ * @Then the response should have a list of active sessions with the length :length
+ */
+ public function theResponseShouldHaveActiveSessions($length) {
+ $board = $this->boardContext->getLastUsedBoard();
+ Assert::assertEquals($length, count($board['activeSessions']), "unexpected count of active sessions");
+ }
+
+ /**
+ * @Then the user :user should be in the list of active sessions
+ */
+ public function theUserShouldBeInTheListOfActiveSessions($user) {
+ $board = $this->boardContext->getLastUsedBoard();
+ Assert::assertContains($user, $board['activeSessions'], "user is not found in the list of active sessions");
+ }
+
+ /**
+ * @When user closes the board named :name
+ */
+ public function closingTheBoardNamed($name) {
+ $board = $this->boardContext->getLastUsedBoard();
+ if (!$board || $board['title'] != $name) {
+ $this->boardContext->fetchesTheBoardNamed($name);
+ $board = $this->boardContext->getLastUsedBoard();
+ }
+
+ $user = $this->serverContext->getCurrentUser();
+ $token = $this->token[$user];
+ Assert::assertNotEmpty($token, "no token for the user found");
+ $this->requestContext->sendOCSRequest('POST', '/apps/deck/api/v1.0/session/close', [
+ 'boardId' => $board['id'],
+ 'token' => $token
+ ]);
+ }
+}
diff --git a/tests/integration/features/sessions.feature b/tests/integration/features/sessions.feature
new file mode 100644
index 000000000..e210f1d2b
--- /dev/null
+++ b/tests/integration/features/sessions.feature
@@ -0,0 +1,33 @@
+Feature: Sessions
+
+ Background:
+ Given user "admin" exists
+ And user "user0" exists
+ And user "user1" exists
+ Given acting as user "user0"
+ And creates a board named "Shared board" with color "fafafa"
+ And shares the board with user "user1"
+
+
+ Scenario: Open a board with multiple users
+ Given acting as user "user0"
+ And user opens the board named "Shared board"
+ When fetches the board named "Shared board"
+ Then the response should have a status code "200"
+ And the response should have a list of active sessions with the length 1
+ And the user "user0" should be in the list of active sessions
+
+ Given acting as user "user1"
+ And user opens the board named "Shared board"
+ When fetches the board named "Shared board"
+ Then the response should have a status code "200"
+ And the response should have a list of active sessions with the length 2
+ And the user "user0" should be in the list of active sessions
+ And the user "user1" should be in the list of active sessions
+
+ When user closes the board named "Shared board"
+ And fetches the board named "Shared board"
+ Then the response should have a status code "200"
+ And the response should have a list of active sessions with the length 1
+ And the user "user0" should be in the list of active sessions
+
diff --git a/tests/stub.phpstub b/tests/stub.phpstub
index a5ab068eb..156fff6ff 100644
--- a/tests/stub.phpstub
+++ b/tests/stub.phpstub
@@ -661,3 +661,16 @@ namespace OC\Files\Storage\Wrapper{
public function getQuota() {}
}
}
+
+
+namespace OCA\NotifyPush\Queue {
+
+ interface IQueue {
+ /**
+ * @param string $channel
+ * @param mixed $message
+ * @return void
+ */
+ public function push(string $channel, $message);
+ }
+}
\ No newline at end of file
diff --git a/tests/unit/Db/BoardTest.php b/tests/unit/Db/BoardTest.php
index 0c1814705..57f2492ad 100644
--- a/tests/unit/Db/BoardTest.php
+++ b/tests/unit/Db/BoardTest.php
@@ -32,6 +32,7 @@ class BoardTest extends TestCase {
'archived' => false,
'users' => ['user1', 'user2'],
'settings' => [],
+ 'activeSessions' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize());
}
@@ -55,6 +56,7 @@ class BoardTest extends TestCase {
'archived' => false,
'users' => ['user1', 'user2'],
'settings' => [],
+ 'activeSessions' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize());
}
@@ -76,6 +78,7 @@ class BoardTest extends TestCase {
'archived' => false,
'users' => [],
'settings' => [],
+ 'activeSessions' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize());
}
@@ -105,6 +108,7 @@ class BoardTest extends TestCase {
'shared' => 1,
'users' => [],
'settings' => [],
+ 'activeSessions' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize());
}
diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php
index a54697132..69ac2fa5c 100644
--- a/tests/unit/Service/BoardServiceTest.php
+++ b/tests/unit/Service/BoardServiceTest.php
@@ -34,6 +34,8 @@ use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\ChangeHelper;
use OCA\Deck\Db\LabelMapper;
+use OCA\Deck\Db\Session;
+use OCA\Deck\Db\SessionMapper;
use OCA\Deck\Db\StackMapper;
use OCA\Deck\Event\AclCreatedEvent;
use OCA\Deck\Event\AclDeletedEvent;
@@ -89,6 +91,8 @@ class BoardServiceTest extends TestCase {
private $connection;
/** @var BoardServiceValidator */
private $boardServiceValidator;
+ /** @var SessionMapper */
+ private $sessionMapper;
public function setUp(): void {
parent::setUp();
@@ -110,6 +114,7 @@ class BoardServiceTest extends TestCase {
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->connection = $this->createMock(IDBConnection::class);
$this->boardServiceValidator = $this->createMock(BoardServiceValidator::class);
+ $this->sessionMapper = $this->createMock(SessionMapper::class);
$this->service = new BoardService(
$this->boardMapper,
@@ -130,6 +135,7 @@ class BoardServiceTest extends TestCase {
$this->urlGenerator,
$this->connection,
$this->boardServiceValidator,
+ $this->sessionMapper,
$this->userId
);
@@ -172,6 +178,11 @@ class BoardServiceTest extends TestCase {
->willReturn([
'admin' => 'admin',
]);
+ $session = $this->createMock(Session::class);
+ $this->sessionMapper->expects($this->once())
+ ->method('findAllActive')
+ ->with(1)
+ ->willReturn([$session]);
$this->assertEquals($b1, $this->service->find(1));
}
@@ -224,6 +235,9 @@ class BoardServiceTest extends TestCase {
->willReturn([
'admin' => 'admin',
]);
+ $this->sessionMapper->expects($this->once())
+ ->method('findAllActive')
+ ->willReturn([]);
$b = $this->service->update(123, 'MyNewNameBoard', 'ffffff', false);
$this->assertEquals($b->getTitle(), 'MyNewNameBoard');
@@ -244,6 +258,10 @@ class BoardServiceTest extends TestCase {
->willReturn([
'admin' => 'admin',
]);
+ $this->sessionMapper->expects($this->once())
+ ->method('findAllActive')
+ ->with(null)
+ ->willReturn([]);
$boardDeleted = clone $board;
$boardDeleted->setDeletedAt(1);
$this->boardMapper->expects($this->once())