Merge pull request #3876 from alangecker/sessions

basic notify_push usage with session handling
This commit is contained in:
Marcel Klehr
2023-01-03 15:42:28 +01:00
committed by GitHub
33 changed files with 1479 additions and 3 deletions

View File

@@ -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>

View File

@@ -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'],
]
];

View File

@@ -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": []
}
}
```

View File

@@ -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 {

View 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();
}
}

View 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');
}
}

View File

@@ -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
View 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
View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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]);
}
}
}

View 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
View 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';
}

View File

@@ -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

View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View 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>

View File

@@ -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

View File

@@ -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')

View 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
View 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)
},
}
}

View File

@@ -11,3 +11,4 @@ default:
- CommentContext
- AttachmentContext
- SearchContext
- SessionContext

View File

@@ -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 |

View File

@@ -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);
}

View File

@@ -47,4 +47,8 @@ class ServerContext implements Context {
public function getReqestToken(): string {
return $this->requestToken;
}
public function getCurrentUser(): string {
return $this->currentUser;
}
}

View 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
]);
}
}

View 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

View File

@@ -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);
}
}

View File

@@ -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());
}

View File

@@ -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())