From fcfbcc63b4ac0a304202ad1451d82b591d282c90 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Thu, 4 Aug 2022 14:42:27 +0200
Subject: [PATCH 01/33] 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 @@
+
(a.title < b.title) ? -1 : 1)
},
+ isNotifyPushEnabled() {
+ return isNotifyPushEnabled()
+ },
},
watch: {
board(current, previous) {
diff --git a/src/components/SessionList.vue b/src/components/SessionList.vue
new file mode 100644
index 000000000..ebd3c90ab
--- /dev/null
+++ b/src/components/SessionList.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index ae5492436..1bc019ab0 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -81,6 +81,8 @@ import Stack from './Stack.vue'
import { NcEmptyContent } from '@nextcloud/vue'
import GlobalSearchResults from '../search/GlobalSearchResults.vue'
import { showError } from '../../helpers/errors.js'
+import { sessionApi } from '../../services/SessionApi'
+import { isNotifyPushEnabled } from '../../listeners'
export default {
name: 'Board',
@@ -128,13 +130,51 @@ export default {
},
},
watch: {
- id: 'fetchData',
+ id(newValue, oldValue) {
+ if (oldValue) {
+ // close old session
+ sessionApi.closeSession(oldValue, this.token)
+ this.token = null
+ }
+ // create new session
+ this.ensureSession(newValue)
+
+ this.fetchData()
+ },
showArchived() {
this.fetchData()
},
},
created() {
+ if (isNotifyPushEnabled()) {
+ // create a session
+ this.ensureSession()
+ }
+
this.fetchData()
+
+ if (isNotifyPushEnabled()) {
+ // regularly let the server know that we are still here
+ this.sessionInterval = setInterval(() => {
+ this.ensureSession()
+ }, 25 * 1000)
+
+ // we don't get events pushed for sessions that have expired,
+ // so we poll the list of sessions every minute when there
+ // are other sessions active
+ this.refreshInterval = setInterval(() => {
+ if (this.board?.activeSessions?.length) {
+ this.refreshData()
+ }
+ }, 60 * 1000)
+ }
+ },
+ beforeDestroy() {
+ if (isNotifyPushEnabled()) {
+ sessionApi.closeSession(this.id, this.token)
+ clearInterval(this.sessionInterval)
+ clearInterval(this.refreshInterval)
+ }
},
methods: {
async fetchData() {
@@ -149,6 +189,32 @@ export default {
this.loading = false
},
+ async ensureSession(boardId = this.id) {
+ if (this.token) {
+ try {
+ await sessionApi.syncSession(boardId, this.token)
+ } catch (err) {
+ // session probably expired, let's try again
+ // with a fresh session
+ this.token = null
+ setTimeout(() => {
+ this.ensureSession()
+ }, 100)
+ }
+ } else {
+ try {
+ const res = await sessionApi.createSession(boardId)
+ this.token = res.token
+ } catch (err) {
+ showError(err)
+ }
+ }
+ },
+
+ async refreshData() {
+ await this.$store.dispatch('refreshBoard', this.id)
+ },
+
onDropStack({ removedIndex, addedIndex }) {
this.$store.dispatch('orderStack', { stack: this.stacksByBoard[removedIndex], removedIndex, addedIndex })
},
diff --git a/src/listeners.js b/src/listeners.js
new file mode 100644
index 000000000..4f99ebf10
--- /dev/null
+++ b/src/listeners.js
@@ -0,0 +1,41 @@
+/*
+ * @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 store from './store/main'
+
+let hasPush = false
+
+/**
+ * is the notify_push app active and can
+ * provide us with real time updates?
+ */
+export function isNotifyPushEnabled() {
+ return hasPush
+}
+
+hasPush = listen('DeckBoardUpdate', (name, body) => {
+ const currentBoardId = store.state.currentBoard?.id
+
+ // only handle update event for the currently open board
+ if (body.id !== currentBoardId) return
+
+ store.dispatch('refreshBoard', currentBoardId)
+})
diff --git a/src/main.js b/src/main.js
index 2d1a28132..d7857426b 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 './listeners'
// 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..33c52f4e6
--- /dev/null
+++ b/src/services/SessionApi.js
@@ -0,0 +1,45 @@
+/*
+ * @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 { generateUrl } from '@nextcloud/router'
+
+export class SessionApi {
+
+ url(url) {
+ url = `/apps/deck${url}`
+ return generateUrl(url)
+ }
+
+ async createSession(boardId) {
+ return (await axios.put(this.url('/session/create'), { boardId })).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 })
+ }
+
+}
+
+export const sessionApi = new SessionApi()
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())
From fd6e15b58c2d4abff058e6a5eac095208057ca9c Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Thu, 4 Aug 2022 15:22:09 +0200
Subject: [PATCH 02/33] ensure activeSessions is an array
sometimes the resulting json contained an object leading to JS errors
Signed-off-by: chandi Langecker
---
lib/Service/BoardService.php | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php
index 63ca79c26..a7388f29d 100644
--- a/lib/Service/BoardService.php
+++ b/lib/Service/BoardService.php
@@ -457,9 +457,13 @@ class BoardService {
public function enrichWithActiveSessions(Board $board) {
$sessions = $this->sessionMapper->findAllActive($board->getId());
- $board->setActiveSessions(array_unique(array_map(function (Session $session) {
- return $session->getUserId();
- }, $sessions)));
+ $board->setActiveSessions(array_values(
+ array_unique(
+ array_map(function (Session $session) {
+ return $session->getUserId();
+ }, $sessions)
+ )
+ ));
}
/**
From 7dc64de2ded6e32dc0e9e9ebd9848e2d37bdd3ca Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Fri, 5 Aug 2022 00:30:20 +0200
Subject: [PATCH 03/33] dedicated Listener class, constants for events
Signed-off-by: chandi Langecker
---
lib/AppInfo/Application.php | 37 ++------------
lib/Listeners/LiveUpdateListener.php | 73 ++++++++++++++++++++++++++++
lib/NotifyPushEvents.php | 26 ++++++++++
lib/Service/SessionService.php | 3 ++
4 files changed, 107 insertions(+), 32 deletions(-)
create mode 100644 lib/Listeners/LiveUpdateListener.php
create mode 100644 lib/NotifyPushEvents.php
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index e15e111ce..b3695e1eb 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -46,6 +46,7 @@ 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;
@@ -53,10 +54,8 @@ 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;
@@ -100,7 +99,6 @@ 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);
@@ -152,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 {
@@ -193,33 +195,4 @@ 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/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php
new file mode 100644
index 000000000..06eecfe4f
--- /dev/null
+++ b/lib/Listeners/LiveUpdateListener.php
@@ -0,0 +1,73 @@
+
+ *
+ * @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 Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+class LiveUpdateListener implements IEventListener {
+ private string $userId;
+ private LoggerInterface $logger;
+ private SessionService $sessionService;
+ private $queue;
+
+ public function __construct(ContainerInterface $container, SessionService $sessionService, $userId) {
+ try {
+ $this->queue = $container->get(IQueue::class);
+ } catch (\Exception $e) {
+ // most likely notify_push is not installed.
+ return;
+ }
+ $this->userId = $userId;
+ $this->logger = $container->get(LoggerInterface::class);
+ $this->sessionService = $sessionService;
+ }
+
+ public function handle(Event $event): void {
+ if (!$this->queue) {
+ // notify_push is not active
+ return;
+ }
+
+ try {
+ if ($event instanceof SessionCreatedEvent || $event instanceof SessionClosedEvent) {
+ $this->sessionService->notifyAllSessions($this->queue, $event->getBoardId(), NotifyPushEvents::DeckBoardUpdate, $event->getUserId(), [
+ 'id' => $event->getBoardId()
+ ]);
+ }
+ } catch (\Exception $e) {
+ $this->logger->error('Error when handling live update event', ['exception' => $e]);
+ }
+ }
+}
diff --git a/lib/NotifyPushEvents.php b/lib/NotifyPushEvents.php
new file mode 100644
index 000000000..3fed8d11d
--- /dev/null
+++ b/lib/NotifyPushEvents.php
@@ -0,0 +1,26 @@
+.
+ *
+ */
+
+namespace OCA\Deck;
+
+class NotifyPushEvents {
+ public const DeckBoardUpdate = 'deck_board_update';
+}
diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php
index 28f0fc91e..bd8ab34cc 100644
--- a/lib/Service/SessionService.php
+++ b/lib/Service/SessionService.php
@@ -66,6 +66,9 @@ class SessionService {
return $session;
}
+ /**
+ * @throws DoesNotExistException
+ */
public function syncSession(int $boardId, string $token) {
$session = $this->sessionMapper->find($boardId, $this->userId, $token);
$session->setLastContact($this->timeFactory->getTime());
From 6bfb54e2b3502984aae51d649db700e11c01b8e4 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 11:27:00 +0200
Subject: [PATCH 04/33] use strict types in new php classes
Signed-off-by: chandi Langecker
---
lib/Controller/SessionController.php | 3 +++
lib/Db/Session.php | 3 +++
lib/Db/SessionMapper.php | 3 +++
lib/Event/SessionClosedEvent.php | 5 ++++-
lib/Event/SessionCreatedEvent.php | 5 ++++-
lib/NotifyPushEvents.php | 3 +++
lib/Service/SessionService.php | 3 +++
7 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php
index 784596b2b..d0cf5485a 100644
--- a/lib/Controller/SessionController.php
+++ b/lib/Controller/SessionController.php
@@ -1,4 +1,7 @@
boardId = $boardId;
diff --git a/lib/Event/SessionCreatedEvent.php b/lib/Event/SessionCreatedEvent.php
index 8182f1e74..8c2e3c11c 100644
--- a/lib/Event/SessionCreatedEvent.php
+++ b/lib/Event/SessionCreatedEvent.php
@@ -1,4 +1,7 @@
boardId = $boardId;
diff --git a/lib/NotifyPushEvents.php b/lib/NotifyPushEvents.php
index 3fed8d11d..f12daa582 100644
--- a/lib/NotifyPushEvents.php
+++ b/lib/NotifyPushEvents.php
@@ -1,4 +1,7 @@
Date: Mon, 5 Sep 2022 11:53:13 +0200
Subject: [PATCH 05/33] better session handling
- separated from component
- handle visibilityState / closing of the tab
Signed-off-by: chandi Langecker
---
lib/Controller/SessionController.php | 1 +
src/components/Controls.vue | 2 +-
src/components/board/Board.vue | 61 ++------------
src/listeners.js | 41 ---------
src/main.js | 2 +-
src/services/SessionApi.js | 12 +++
src/sessions.js | 122 +++++++++++++++++++++++++++
7 files changed, 143 insertions(+), 98 deletions(-)
delete mode 100644 src/listeners.js
create mode 100644 src/sessions.js
diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php
index d0cf5485a..c42cb7b46 100644
--- a/lib/Controller/SessionController.php
+++ b/lib/Controller/SessionController.php
@@ -80,6 +80,7 @@ class SessionController extends ApiController {
/**
* delete a session if existing
* @NoAdminRequired
+ * @NoCSRFRequired
* @param $boardId
* @return bool
*/
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index e1f55daba..a29cc5a2e 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -227,7 +227,7 @@ import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
import SessionList from './SessionList'
-import { isNotifyPushEnabled } from '../listeners'
+import { isNotifyPushEnabled } from '../sessions'
export default {
name: 'Controls',
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index 1bc019ab0..05e8be3db 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -81,8 +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 { sessionApi } from '../../services/SessionApi'
-import { isNotifyPushEnabled } from '../../listeners'
+import { createSession } from '../../sessions.js'
export default {
name: 'Board',
@@ -131,13 +130,11 @@ export default {
},
watch: {
id(newValue, oldValue) {
- if (oldValue) {
+ if (this.session) {
// close old session
- sessionApi.closeSession(oldValue, this.token)
- this.token = null
+ this.session.close()
}
- // create new session
- this.ensureSession(newValue)
+ this.session = createSession(newValue)
this.fetchData()
},
@@ -146,35 +143,11 @@ export default {
},
},
created() {
- if (isNotifyPushEnabled()) {
- // create a session
- this.ensureSession()
- }
-
+ this.session = createSession(this.id)
this.fetchData()
-
- if (isNotifyPushEnabled()) {
- // regularly let the server know that we are still here
- this.sessionInterval = setInterval(() => {
- this.ensureSession()
- }, 25 * 1000)
-
- // we don't get events pushed for sessions that have expired,
- // so we poll the list of sessions every minute when there
- // are other sessions active
- this.refreshInterval = setInterval(() => {
- if (this.board?.activeSessions?.length) {
- this.refreshData()
- }
- }, 60 * 1000)
- }
},
beforeDestroy() {
- if (isNotifyPushEnabled()) {
- sessionApi.closeSession(this.id, this.token)
- clearInterval(this.sessionInterval)
- clearInterval(this.refreshInterval)
- }
+ this.session.close()
},
methods: {
async fetchData() {
@@ -189,28 +162,6 @@ export default {
this.loading = false
},
- async ensureSession(boardId = this.id) {
- if (this.token) {
- try {
- await sessionApi.syncSession(boardId, this.token)
- } catch (err) {
- // session probably expired, let's try again
- // with a fresh session
- this.token = null
- setTimeout(() => {
- this.ensureSession()
- }, 100)
- }
- } else {
- try {
- const res = await sessionApi.createSession(boardId)
- this.token = res.token
- } catch (err) {
- showError(err)
- }
- }
- },
-
async refreshData() {
await this.$store.dispatch('refreshBoard', this.id)
},
diff --git a/src/listeners.js b/src/listeners.js
deleted file mode 100644
index 4f99ebf10..000000000
--- a/src/listeners.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * @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 store from './store/main'
-
-let hasPush = false
-
-/**
- * is the notify_push app active and can
- * provide us with real time updates?
- */
-export function isNotifyPushEnabled() {
- return hasPush
-}
-
-hasPush = listen('DeckBoardUpdate', (name, body) => {
- const currentBoardId = store.state.currentBoard?.id
-
- // only handle update event for the currently open board
- if (body.id !== currentBoardId) return
-
- store.dispatch('refreshBoard', currentBoardId)
-})
diff --git a/src/main.js b/src/main.js
index d7857426b..8f519b11e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -31,7 +31,7 @@ import { subscribe } from '@nextcloud/event-bus'
import { Tooltip } from '@nextcloud/vue'
import ClickOutside from 'vue-click-outside'
import './models/index.js'
-import './listeners'
+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
index 33c52f4e6..2bcf9d4d0 100644
--- a/src/services/SessionApi.js
+++ b/src/services/SessionApi.js
@@ -40,6 +40,18 @@ export class SessionApi {
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..7b86fdb47
--- /dev/null
+++ b/src/sessions.js
@@ -0,0 +1,122 @@
+/*
+ * @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'
+import store from './store/main'
+
+const SESSION_INTERVAL = 25 // in seconds
+
+let hasPush = false
+
+hasPush = listen('deck_board_update', (name, body) => {
+ triggerDeckReload(body.id)
+})
+
+/**
+ * is the notify_push app active and can
+ * provide us with real time updates?
+ */
+export function isNotifyPushEnabled() {
+ return hasPush
+}
+
+/**
+ * Triggers a reload of the deck, if the provided id
+ * matches the current open deck
+ *
+ * @param triggeredBoardId
+ */
+export function triggerDeckReload(triggeredBoardId) {
+ const currentBoardId = store.state.currentBoard?.id
+
+ // only handle update events for the currently open board
+ if (triggeredBoardId !== currentBoardId) return
+
+ store.dispatch('refreshBoard', currentBoardId)
+}
+
+/**
+ *
+ * @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
+ })
+ }
+ 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
+ const interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
+
+ // close session when
+ const visibilitychangeListener = () => {
+ if (document.visibilityState === 'hidden') {
+ sessionApi.closeSessionViaBeacon(boardId, token)
+ tokenPromise = null
+ token = null
+ } 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.
+ triggerDeckReload()
+ }
+ }
+ document.addEventListener('visibilitychange', visibilitychangeListener)
+
+ return {
+ async close() {
+ clearInterval(interval)
+ document.removeEventListener('visibilitychange', visibilitychangeListener)
+ await sessionApi.closeSession(boardId, await tokenPromise)
+ },
+ }
+}
From 567b9cc66e8c31527b6144f657f36e5d9dc68665 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 11:54:30 +0200
Subject: [PATCH 06/33] use data() for constants instead of a computed property
Signed-off-by: chandi Langecker
---
src/components/Controls.vue | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index a29cc5a2e..205486108 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -268,6 +268,7 @@ export default {
filter: { tags: [], users: [], due: '', unassigned: false },
showAddCardModal: false,
defaultPageTitle: false,
+ isNotifyPushEnabled: isNotifyPushEnabled(),
}
},
@@ -291,9 +292,6 @@ export default {
labelsSorted() {
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
- isNotifyPushEnabled() {
- return isNotifyPushEnabled()
- },
},
watch: {
board(current, previous) {
From dd307fa353335d5e9d93683f23ed2b69d7b4ac0b Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 12:02:35 +0200
Subject: [PATCH 07/33] use nextcloud's database type constants
Signed-off-by: chandi Langecker
---
lib/Migration/Version10900Date202206151724222.php | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/lib/Migration/Version10900Date202206151724222.php b/lib/Migration/Version10900Date202206151724222.php
index 58fddf162..1fac4d10a 100644
--- a/lib/Migration/Version10900Date202206151724222.php
+++ b/lib/Migration/Version10900Date202206151724222.php
@@ -26,6 +26,7 @@ namespace OCA\Deck\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
@@ -35,23 +36,23 @@ class Version10900Date202206151724222 extends SimpleMigrationStep {
if (!$schema->hasTable('deck_sessions')) {
$table = $schema->createTable('deck_sessions');
- $table->addColumn('id', 'integer', [
+ $table->addColumn('id', Types::INTEGER, [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
- $table->addColumn('user_id', 'string', [
+ $table->addColumn('user_id', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
- $table->addColumn('board_id', 'integer', [
+ $table->addColumn('board_id', Types::INTEGER, [
'notnull' => false,
]);
- $table->addColumn('token', 'string', [
+ $table->addColumn('token', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
- $table->addColumn('last_contact', 'integer', [
+ $table->addColumn('last_contact', Types::INTEGER, [
'notnull' => true,
'length' => 20,
'unsigned' => true,
From cd7fb9a4bd0984dd5f86e8602ac8e5b82d0fbc28 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 12:06:22 +0200
Subject: [PATCH 08/33] increase session timeout
Signed-off-by: chandi Langecker
---
lib/Service/SessionService.php | 2 +-
src/sessions.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php
index 3ef033ef4..0d26d822e 100644
--- a/lib/Service/SessionService.php
+++ b/lib/Service/SessionService.php
@@ -35,7 +35,7 @@ use OCA\NotifyPush\Queue\IQueue;
use OCP\Security\ISecureRandom;
class SessionService {
- public const SESSION_VALID_TIME = 30;
+ public const SESSION_VALID_TIME = 92;
private SessionMapper $sessionMapper;
private ITimeFactory $timeFactory;
diff --git a/src/sessions.js b/src/sessions.js
index 7b86fdb47..740529276 100644
--- a/src/sessions.js
+++ b/src/sessions.js
@@ -22,7 +22,7 @@ import { listen } from '@nextcloud/notify_push'
import { sessionApi } from './services/SessionApi'
import store from './store/main'
-const SESSION_INTERVAL = 25 // in seconds
+const SESSION_INTERVAL = 90 // in seconds
let hasPush = false
From b3d4ac52186dd2a095b53a337516ee703e8f98ac Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 12:12:38 +0200
Subject: [PATCH 09/33] don't refresh closed sessions when tab is in the
background
Signed-off-by: chandi Langecker
---
src/sessions.js | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/sessions.js b/src/sessions.js
index 740529276..48ee2ea89 100644
--- a/src/sessions.js
+++ b/src/sessions.js
@@ -93,7 +93,7 @@ export function createSession(boardId) {
}
// periodically notify the server that we are still here
- const interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
+ let interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
// close session when
const visibilitychangeListener = () => {
@@ -101,6 +101,9 @@ export function createSession(boardId) {
sessionApi.closeSessionViaBeacon(boardId, token)
tokenPromise = null
token = null
+
+ // stop session refresh interval
+ clearInterval(interval)
} else {
// tab is back in focus or was restored from the bfcache
ensureSession()
@@ -108,6 +111,9 @@ export function createSession(boardId) {
// we must assume that the websocket connection was
// paused and we have missed updates in the meantime.
triggerDeckReload()
+
+ // restart session refresh interval
+ interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
}
}
document.addEventListener('visibilitychange', visibilitychangeListener)
From af134959ce56ab78dc026cb3ebbb245a19b427c9 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 13:56:15 +0200
Subject: [PATCH 10/33] optimize sql queries / indexing
Signed-off-by: chandi Langecker
---
lib/Db/SessionMapper.php | 10 ++++++----
lib/Migration/Version10900Date202206151724222.php | 2 +-
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php
index 84d552474..b01281738 100644
--- a/lib/Db/SessionMapper.php
+++ b/lib/Db/SessionMapper.php
@@ -38,9 +38,7 @@ class SessionMapper extends QBMapper {
$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)))
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token)))
->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME)))
->executeQuery();
@@ -49,7 +47,11 @@ class SessionMapper extends QBMapper {
if ($data === false) {
throw new DoesNotExistException('Session is invalid');
}
- return Session::fromRow($data);
+ $session = Session::fromRow($data);
+ if ($session->getUserId() != $userId || $session->getBoardId() != $boardId) {
+ throw new DoesNotExistException('Session is invalid');
+ }
+ return $session;
}
public function findAllActive($boardId) {
diff --git a/lib/Migration/Version10900Date202206151724222.php b/lib/Migration/Version10900Date202206151724222.php
index 1fac4d10a..f2806e946 100644
--- a/lib/Migration/Version10900Date202206151724222.php
+++ b/lib/Migration/Version10900Date202206151724222.php
@@ -58,8 +58,8 @@ class Version10900Date202206151724222 extends SimpleMigrationStep {
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
+ $table->addIndex(['board_id'], 'rd_session_board_id_idx');
$table->addIndex(['token'], 'rd_session_token_idx');
- $table->addIndex(['last_contact'], 'ts_lastcontact');
}
return $schema;
}
From 0aede224ec8acdaa531988993308f9b3500b045c Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 5 Sep 2022 13:59:15 +0200
Subject: [PATCH 11/33] remove refreshData() leftover
there is now no regular refreshing anymore, only after update events
Signed-off-by: chandi Langecker
---
src/components/board/Board.vue | 4 ----
1 file changed, 4 deletions(-)
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
index 05e8be3db..b7c2473d3 100644
--- a/src/components/board/Board.vue
+++ b/src/components/board/Board.vue
@@ -162,10 +162,6 @@ export default {
this.loading = false
},
- async refreshData() {
- await this.$store.dispatch('refreshBoard', this.id)
- },
-
onDropStack({ removedIndex, addedIndex }) {
this.$store.dispatch('orderStack', { stack: this.stacksByBoard[removedIndex], removedIndex, addedIndex })
},
From 0272b2d52f0171c740456e1ed7d2b37a1661b4f3 Mon Sep 17 00:00:00 2001
From: chandi Langecker
Date: Mon, 26 Sep 2022 16:18:36 +0200
Subject: [PATCH 12/33] fix linter errors after rebase
Signed-off-by: chandi Langecker
---
src/components/Controls.vue | 4 ++--
src/components/SessionList.vue | 3 +--
src/sessions.js | 4 ++--
3 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index 205486108..23ec784ba 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -226,8 +226,8 @@ import FilterIcon from 'vue-material-design-icons/Filter.vue'
import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
-import SessionList from './SessionList'
-import { isNotifyPushEnabled } from '../sessions'
+import SessionList from './SessionList.vue'
+import { isNotifyPushEnabled } from '../sessions.js'
export default {
name: 'Controls',
diff --git a/src/components/SessionList.vue b/src/components/SessionList.vue
index ebd3c90ab..c40853aaf 100644
--- a/src/components/SessionList.vue
+++ b/src/components/SessionList.vue
@@ -39,8 +39,7 @@