Merge pull request #3876 from alangecker/sessions
basic notify_push usage with session handling
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
<job>OCA\Deck\Cron\DeleteCron</job>
|
||||
<job>OCA\Deck\Cron\ScheduledNotifications</job>
|
||||
<job>OCA\Deck\Cron\CardDescriptionActivity</job>
|
||||
<job>OCA\Deck\Cron\SessionsCleanup</job>
|
||||
</background-jobs>
|
||||
<repair-steps>
|
||||
<live-migration>
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
];
|
||||
|
||||
107
docs/API.md
107
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": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 {
|
||||
|
||||
91
lib/Controller/SessionController.php
Normal file
91
lib/Controller/SessionController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
56
lib/Cron/SessionsCleanup.php
Normal file
56
lib/Cron/SessionsCleanup.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
51
lib/Db/Session.php
Normal file
51
lib/Db/Session.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
lib/Db/SessionMapper.php
Normal file
74
lib/Db/SessionMapper.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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<Session> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
48
lib/Event/SessionClosedEvent.php
Normal file
48
lib/Event/SessionClosedEvent.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
49
lib/Event/SessionCreatedEvent.php
Normal file
49
lib/Event/SessionCreatedEvent.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
87
lib/Listeners/LiveUpdateListener.php
Normal file
87
lib/Listeners/LiveUpdateListener.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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<Event|SessionCreatedEvent|SessionClosedEvent> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
lib/Migration/Version10900Date202206151724222.php
Normal file
67
lib/Migration/Version10900Date202206151724222.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
29
lib/NotifyPushEvents.php
Normal file
29
lib/NotifyPushEvents.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Deck;
|
||||
|
||||
class NotifyPushEvents {
|
||||
public const DeckBoardUpdate = 'deck_board_update';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
127
lib/Service/SessionService.php
Normal file
127
lib/Service/SessionService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
package-lock.json
generated
155
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="board-actions">
|
||||
<SessionList v-if="isNotifyPushEnabled && presentUsers.length"
|
||||
:sessions="presentUsers" />
|
||||
<div v-if="searchQuery || true" class="deck-search">
|
||||
<input type="search"
|
||||
class="icon-search"
|
||||
@@ -224,6 +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.vue'
|
||||
import { isNotifyPushEnabled } from '../sessions.js'
|
||||
|
||||
export default {
|
||||
name: 'Controls',
|
||||
@@ -239,6 +243,7 @@ export default {
|
||||
FilterOffIcon,
|
||||
ArrowCollapseVerticalIcon,
|
||||
ArrowExpandVerticalIcon,
|
||||
SessionList,
|
||||
},
|
||||
mixins: [labelStyle],
|
||||
props: {
|
||||
@@ -263,6 +268,7 @@ export default {
|
||||
filter: { tags: [], users: [], due: '', unassigned: false },
|
||||
showAddCardModal: false,
|
||||
defaultPageTitle: false,
|
||||
isNotifyPushEnabled: isNotifyPushEnabled(),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -286,6 +292,11 @@ export default {
|
||||
labelsSorted() {
|
||||
return [...this.board.labels].sort((a, b) => (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) {
|
||||
|
||||
112
src/components/SessionList.vue
Normal file
112
src/components/SessionList.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<!--
|
||||
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
|
||||
* @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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* -->
|
||||
|
||||
<template>
|
||||
<div v-tooltip.bottom="t('text', 'Currently present people')"
|
||||
class="avatar-list">
|
||||
<div v-for="session in sessionsVisible"
|
||||
:key="session.uid"
|
||||
class="avatar-wrapper"
|
||||
:style="sessionAvatarStyle">
|
||||
<NcAvatar :user="session.uid"
|
||||
:display-name="session.displayname"
|
||||
:disable-menu="true"
|
||||
:show-user-status="false"
|
||||
:disable-tooltip="true"
|
||||
:size="size" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAvatar, Tooltip } from '@nextcloud/vue'
|
||||
|
||||
export default {
|
||||
name: 'SessionList',
|
||||
components: {
|
||||
NcAvatar,
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip,
|
||||
},
|
||||
props: {
|
||||
sessions: {
|
||||
type: Array,
|
||||
default: () => { return [] },
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: () => 32,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sessionsVisible() {
|
||||
if (!this.sessions) return []
|
||||
return this.sessions.slice(0, 5)
|
||||
},
|
||||
sessionAvatarStyle() {
|
||||
return {
|
||||
'--size': this.size + 'px',
|
||||
'--font-size': this.size / 2 + 'px',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.avatar-list {
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
border: none;
|
||||
background-color: var(--color-main-background);
|
||||
margin: 0;
|
||||
padding-left: 6px;
|
||||
display: inline-flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
&:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
background-color: #b9b9b9;
|
||||
border-radius: 50%;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
line-height: var(--size);
|
||||
font-size: var(--font-size);
|
||||
font-weight: normal;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
box-sizing: content-box !important;
|
||||
margin-left: -8px;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
56
src/services/SessionApi.js
Normal file
56
src/services/SessionApi.js
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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()
|
||||
137
src/sessions.js
Normal file
137
src/sessions.js
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ default:
|
||||
- CommentContext
|
||||
- AttachmentContext
|
||||
- SearchContext
|
||||
- SessionContext
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -47,4 +47,8 @@ class ServerContext implements Context {
|
||||
public function getReqestToken(): string {
|
||||
return $this->requestToken;
|
||||
}
|
||||
|
||||
public function getCurrentUser(): string {
|
||||
return $this->currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
80
tests/integration/features/bootstrap/SessionContext.php
Normal file
80
tests/integration/features/bootstrap/SessionContext.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Behat\Behat\Context\Context;
|
||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
class SessionContext implements Context {
|
||||
use RequestTrait;
|
||||
|
||||
/** @var ServerContext */
|
||||
private $serverContext;
|
||||
|
||||
/** @var BoardContext */
|
||||
private $boardContext;
|
||||
|
||||
private $tokens = [];
|
||||
|
||||
/** @BeforeScenario */
|
||||
public function gatherContexts(BeforeScenarioScope $scope) {
|
||||
$environment = $scope->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
tests/integration/features/sessions.feature
Normal file
33
tests/integration/features/sessions.feature
Normal file
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user