diff --git a/appinfo/info.xml b/appinfo/info.xml index 297db3ef1..fa5432a08 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,6 +40,7 @@ OCA\Deck\Cron\DeleteCron OCA\Deck\Cron\ScheduledNotifications OCA\Deck\Cron\CardDescriptionActivity + OCA\Deck\Cron\SessionsCleanup diff --git a/appinfo/routes.php b/appinfo/routes.php index 41f398e11..9105bf8ef 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -150,5 +150,10 @@ return [ ['name' => 'overview_api#upcomingCards', 'url' => '/api/v{apiVersion}/overview/upcoming', 'verb' => 'GET'], ['name' => 'search#search', 'url' => '/api/v{apiVersion}/search', 'verb' => 'GET'], + + // sessions + ['name' => 'Session#create', 'url' => '/api/v{apiVersion}/session/create', 'verb' => 'PUT'], + ['name' => 'Session#sync', 'url' => '/api/v{apiVersion}/session/sync', 'verb' => 'POST'], + ['name' => 'Session#close', 'url' => '/api/v{apiVersion}/session/close', 'verb' => 'POST'], ] ]; diff --git a/docs/API.md b/docs/API.md index 654f78b87..5e95da1db 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1394,3 +1394,110 @@ A bad request response is returned if invalid input values are provided. The res A not found response might be returned if: - The card for the given cardId could not be found - The comment could not be found + + +## Sessions + +### PUT /session/create - creates a new session + +#### Request parameters + +| Parameter | Type | Description | +| --------- | ------- | ---------------------------------------------------- | +| boardId | Integer | The id of the opened board | + +``` +curl -X PUT 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/create' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' \ + -H 'Content-Type: application/json;charset=utf-8' \ + --data '{"boardId":1}' +``` + +#### Response + +##### 200 Success + +```json +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "token": "+zcJHf4rC6dobVSbuNa3delkCSfTW8OvYWTyLFvSpIv80FjtgLIj0ARlxspsazNQ" + } + } +} +``` + + +### POST /session/sync - notifies the server, that the session is still open + +#### Request body + +| Parameter | Type | Description | +| --------- | ------- | ---------------------------------------------------- | +| boardId | Integer | The id of the opened board | +| token | String | The session token from the /sessions/create response | + + +``` +curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/create' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' \ + -H 'Content-Type: application/json;charset=utf-8' \ + --data '{"boardId":1, "token":"X3DyyoFslArF0t0NBZXzZXzcy8feoX/OEytSNXZtPg9TpUgO5wrkJ38IW3T/FfpV"}' +``` + +#### Response + +##### 200 Success +```json +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [] + } +} +``` + +##### 404 Not Found +the provided token is invalid or expired + + +### POST /session/close - closes the session + +#### Request body + +| Parameter | Type | Description | +| --------- | ------- | ---------------------------------------------------- | +| boardId | Integer | The id of the opened board | +| token | String | The session token from the /sessions/create response | + +``` +curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/session/close' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' \ + -H 'Content-Type: application/json;charset=utf-8' \ + --data '{"boardId":1, "token":"X3DyyoFslArF0t0NBZXzZXzcy8feoX/OEytSNXZtPg9TpUgO5wrkJ38IW3T/FfpV"}' +``` + +#### Response + +##### 200 Success +```json +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [] + } +} +``` diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1f9736192..b3695e1eb 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -40,10 +40,13 @@ use OCA\Deck\Event\AclUpdatedEvent; use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; +use OCA\Deck\Event\SessionClosedEvent; +use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Listeners\BeforeTemplateRenderedListener; use OCA\Deck\Listeners\ParticipantCleanupListener; use OCA\Deck\Listeners\FullTextSearchEventListener; use OCA\Deck\Listeners\ResourceListener; +use OCA\Deck\Listeners\LiveUpdateListener; use OCA\Deck\Middleware\DefaultBoardMiddleware; use OCA\Deck\Middleware\ExceptionMiddleware; use OCA\Deck\Notification\Notifier; @@ -147,6 +150,10 @@ class Application extends App implements IBootstrap { $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); + + // Event listening for realtime updates via notify_push + $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); } public function registerNotifications(NotificationManager $notificationManager): void { diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php new file mode 100644 index 000000000..1d1744c9c --- /dev/null +++ b/lib/Controller/SessionController.php @@ -0,0 +1,91 @@ +. + * + */ + +namespace OCA\Deck\Controller; + +use OCA\Deck\Service\SessionService; +use OCA\Deck\Service\PermissionService; +use OCA\Deck\Db\BoardMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCA\Deck\Db\Acl; + +class SessionController extends OCSController { + private SessionService $sessionService; + private PermissionService $permissionService; + private BoardMapper $boardMapper; + + public function __construct($appName, + IRequest $request, + SessionService $sessionService, + PermissionService $permissionService, + BoardMapper $boardMapper + ) { + parent::__construct($appName, $request); + $this->sessionService = $sessionService; + $this->permissionService = $permissionService; + $this->boardMapper = $boardMapper; + } + + /** + * @NoAdminRequired + */ + public function create(int $boardId): DataResponse { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + + $session = $this->sessionService->initSession($boardId); + return new DataResponse([ + 'token' => $session->getToken(), + ]); + } + + /** + * notifies the server that the session is still active + * @NoAdminRequired + * @param $boardId + */ + public function sync(int $boardId, string $token): DataResponse { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + try { + $this->sessionService->syncSession($boardId, $token); + return new DataResponse([]); + } catch (DoesNotExistException $e) { + return new DataResponse([], 404); + } + } + + /** + * delete a session if existing + * @NoAdminRequired + * @NoCSRFRequired + * @param $boardId + */ + public function close(int $boardId, string $token) { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + $this->sessionService->closeSession($boardId, $token); + return new DataResponse(); + } +} diff --git a/lib/Cron/SessionsCleanup.php b/lib/Cron/SessionsCleanup.php new file mode 100644 index 000000000..cbdf18713 --- /dev/null +++ b/lib/Cron/SessionsCleanup.php @@ -0,0 +1,56 @@ +. + * + */ + + + +namespace OCA\Deck\Cron; + +use OCA\Deck\Service\SessionService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\ILogger; + +class SessionsCleanup extends TimedJob { + private $sessionService; + private $documentService; + private $logger; + private $imageService; + + + public function __construct(ITimeFactory $time, + SessionService $sessionService, + ILogger $logger) { + parent::__construct($time); + $this->sessionService = $sessionService; + $this->logger = $logger; + $this->setInterval(SessionService::SESSION_VALID_TIME); + } + + protected function run($argument) { + $this->logger->debug('Run cleanup job for deck sessions'); + + $removedSessions = $this->sessionService->removeInactiveSessions(); + $this->logger->debug('Removed ' . $removedSessions . ' inactive sessions'); + } +} diff --git a/lib/Db/Board.php b/lib/Db/Board.php index 8cc845c35..c5fdb0202 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -44,6 +44,7 @@ class Board extends RelationalEntity { protected $users = []; protected $shared; protected $stacks = []; + protected $activeSessions = []; protected $deletedAt = 0; protected $lastModified = 0; @@ -59,6 +60,7 @@ class Board extends RelationalEntity { $this->addRelation('acl'); $this->addRelation('shared'); $this->addRelation('users'); + $this->addRelation('activeSessions'); $this->addRelation('permissions'); $this->addRelation('stacks'); $this->addRelation('settings'); diff --git a/lib/Db/Session.php b/lib/Db/Session.php new file mode 100644 index 000000000..ae0151ddb --- /dev/null +++ b/lib/Db/Session.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace OCA\Deck\Db; + +use OCP\AppFramework\Db\Entity; + +class Session extends Entity implements \JsonSerializable { + public $id; + protected $userId; + protected $token; + protected $lastContact; + protected $boardId; + + public function __construct() { + $this->addType('id', 'integer'); + $this->addType('boardId', 'integer'); + $this->addType('lastContact', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'userId' => $this->userId, + 'token' => $this->token, + 'lastContact' => $this->lastContact, + 'boardId' => $this->boardId, + ]; + } +} diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php new file mode 100644 index 000000000..8d2a8f6b8 --- /dev/null +++ b/lib/Db/SessionMapper.php @@ -0,0 +1,74 @@ +. + * + */ + +namespace OCA\Deck\Db; + +use OCA\Deck\Service\SessionService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** @template-extends QBMapper */ +class SessionMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'deck_sessions', Session::class); + } + + public function find(int $boardId, string $userId, string $token): Session { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))) + ->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME))) + ->executeQuery(); + + $data = $result->fetch(); + $result->closeCursor(); + if ($data === false) { + throw new DoesNotExistException('Session is invalid'); + } + $session = Session::fromRow($data); + if ($session->getUserId() != $userId || $session->getBoardId() != $boardId) { + throw new DoesNotExistException('Session is invalid'); + } + return $session; + } + + public function findAllActive($boardId) { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'board_id', 'last_contact', 'user_id', 'token') + ->from($this->getTableName()) + ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId))) + ->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME))) + ->executeQuery(); + + return $this->findEntities($qb); + } + public function deleteInactive(): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME))); + return $qb->executeStatement(); + } +} diff --git a/lib/Event/SessionClosedEvent.php b/lib/Event/SessionClosedEvent.php new file mode 100644 index 000000000..3364ea28a --- /dev/null +++ b/lib/Event/SessionClosedEvent.php @@ -0,0 +1,48 @@ +. + * + */ +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCP\EventDispatcher\Event; + +class SessionClosedEvent extends Event { + private $boardId; + private $userId; + + public function __construct(int $boardId, string $userId) { + parent::__construct(); + + $this->boardId = $boardId; + $this->userId = $userId; + } + + public function getBoardId(): int { + return $this->boardId; + } + public function getUserId(): string { + return $this->userId; + } +} diff --git a/lib/Event/SessionCreatedEvent.php b/lib/Event/SessionCreatedEvent.php new file mode 100644 index 000000000..8c2e3c11c --- /dev/null +++ b/lib/Event/SessionCreatedEvent.php @@ -0,0 +1,49 @@ +. + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCP\EventDispatcher\Event; + +class SessionCreatedEvent extends Event { + private $boardId; + private $userId; + + public function __construct(int $boardId, string $userId) { + parent::__construct(); + + $this->boardId = $boardId; + $this->userId = $userId; + } + + public function getBoardId(): int { + return $this->boardId; + } + public function getUserId(): string { + return $this->userId; + } +} diff --git a/lib/Listeners/LiveUpdateListener.php b/lib/Listeners/LiveUpdateListener.php new file mode 100644 index 000000000..8138488f2 --- /dev/null +++ b/lib/Listeners/LiveUpdateListener.php @@ -0,0 +1,87 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Listeners; + +use OCA\Deck\NotifyPushEvents; +use OCA\Deck\Event\SessionClosedEvent; +use OCA\Deck\Event\SessionCreatedEvent; +use OCA\Deck\Service\SessionService; +use OCA\NotifyPush\Queue\IQueue; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IRequest; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener */ +class LiveUpdateListener implements IEventListener { + private LoggerInterface $logger; + private SessionService $sessionService; + private IRequest $request; + private $queue; + + public function __construct( + ContainerInterface $container, + IRequest $request, + LoggerInterface $logger, + SessionService $sessionService + ) { + try { + $this->queue = $container->get(IQueue::class); + } catch (\Exception $e) { + // most likely notify_push is not installed. + return; + } + $this->logger = $logger; + $this->sessionService = $sessionService; + $this->request = $request; + } + + public function handle(Event $event): void { + if (!$this->queue) { + // notify_push is not active + return; + } + + try { + // the web frontend is adding the Session-ID as a header on every request + // TODO: verify the token! this currently allows to spoof a token from someone + // else, preventing this person from getting any live updates + $causingSessionToken = $this->request->getHeader('x-nc-deck-session'); + if ( + $event instanceof SessionCreatedEvent || + $event instanceof SessionClosedEvent + ) { + $this->sessionService->notifyAllSessions($this->queue, $event->getBoardId(), NotifyPushEvents::DeckBoardUpdate, [ + 'id' => $event->getBoardId() + ], $causingSessionToken); + } + } catch (\Exception $e) { + $this->logger->error('Error when handling live update event', ['exception' => $e]); + } + } +} diff --git a/lib/Migration/Version10900Date202206151724222.php b/lib/Migration/Version10900Date202206151724222.php new file mode 100644 index 000000000..037fe56af --- /dev/null +++ b/lib/Migration/Version10900Date202206151724222.php @@ -0,0 +1,67 @@ +. + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version10900Date202206151724222 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + if (!$schema->hasTable('deck_sessions')) { + $table = $schema->createTable('deck_sessions'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('board_id', Types::INTEGER, [ + 'notnull' => false, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('last_contact', Types::INTEGER, [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['board_id'], 'deck_session_board_id_idx'); + $table->addIndex(['token'], 'deck_session_token_idx'); + $table->addIndex(['last_contact'], 'deck_session_last_contact_idx'); + } + return $schema; + } +} diff --git a/lib/NotifyPushEvents.php b/lib/NotifyPushEvents.php new file mode 100644 index 000000000..f12daa582 --- /dev/null +++ b/lib/NotifyPushEvents.php @@ -0,0 +1,29 @@ +. + * + */ + +namespace OCA\Deck; + +class NotifyPushEvents { + public const DeckBoardUpdate = 'deck_board_update'; +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 3c5f76cc7..a7388f29d 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -34,6 +34,8 @@ use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\ChangeHelper; use OCA\Deck\Db\IPermissionMapper; use OCA\Deck\Db\Label; +use OCA\Deck\Db\Session; +use OCA\Deck\Db\SessionMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; use OCA\Deck\Event\AclCreatedEvent; @@ -81,6 +83,7 @@ class BoardService { private IURLGenerator $urlGenerator; private IDBConnection $connection; private BoardServiceValidator $boardServiceValidator; + private SessionMapper $sessionMapper; public function __construct( BoardMapper $boardMapper, @@ -101,6 +104,7 @@ class BoardService { IURLGenerator $urlGenerator, IDBConnection $connection, BoardServiceValidator $boardServiceValidator, + SessionMapper $sessionMapper, ?string $userId ) { $this->boardMapper = $boardMapper; @@ -122,6 +126,7 @@ class BoardService { $this->urlGenerator = $urlGenerator; $this->connection = $connection; $this->boardServiceValidator = $boardServiceValidator; + $this->sessionMapper = $sessionMapper; } /** @@ -214,6 +219,7 @@ class BoardService { ]); $this->enrichWithUsers($board); $this->enrichWithBoardSettings($board); + $this->enrichWithActiveSessions($board); $this->boardsCache[$board->getId()] = $board; return $board; } @@ -448,6 +454,18 @@ class BoardService { $board->setSettings($settings); } + public function enrichWithActiveSessions(Board $board) { + $sessions = $this->sessionMapper->findAllActive($board->getId()); + + $board->setActiveSessions(array_values( + array_unique( + array_map(function (Session $session) { + return $session->getUserId(); + }, $sessions) + ) + )); + } + /** * @param $boardId * @param $type diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php new file mode 100644 index 000000000..a6287a050 --- /dev/null +++ b/lib/Service/SessionService.php @@ -0,0 +1,127 @@ +. + * + */ + +namespace OCA\Deck\Service; + +use OCA\Deck\Db\Session; +use OCA\Deck\Db\SessionMapper; +use OCA\Deck\Event\SessionCreatedEvent; +use OCA\Deck\Event\SessionClosedEvent; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\NotifyPush\Queue\IQueue; +use OCP\Security\ISecureRandom; + +class SessionService { + public const SESSION_VALID_TIME = 92; + + private SessionMapper $sessionMapper; + private ITimeFactory $timeFactory; + private $userId; + private IEventDispatcher $eventDispatcher; + private ISecureRandom $secureRandom; + + public function __construct( + SessionMapper $sessionMapper, + ISecureRandom $secureRandom, + ITimeFactory $timeFactory, + $userId, + IEventDispatcher $eventDispatcher + ) { + $this->sessionMapper = $sessionMapper; + $this->secureRandom = $secureRandom; + $this->timeFactory = $timeFactory; + $this->userId = $userId; + $this->eventDispatcher = $eventDispatcher; + } + + public function initSession(int $boardId): Session { + $session = new Session(); + $session->setBoardId($boardId); + $session->setUserId($this->userId); + $session->setToken($this->secureRandom->generate(32)); + $session->setLastContact($this->timeFactory->getTime()); + + $session = $this->sessionMapper->insert($session); + $this->eventDispatcher->dispatchTyped(new SessionCreatedEvent($boardId, $this->userId)); + return $session; + } + + /** + * @throws DoesNotExistException + */ + public function syncSession(int $boardId, string $token) { + $session = $this->sessionMapper->find($boardId, $this->userId, $token); + $session->setLastContact($this->timeFactory->getTime()); + $this->sessionMapper->update($session); + } + + public function closeSession(int $boardId, string $token): void { + try { + $session = $this->sessionMapper->find($boardId, $this->userId, $token); + $this->sessionMapper->delete($session); + } catch (DoesNotExistException $e) { + } + $this->eventDispatcher->dispatchTyped(new SessionClosedEvent($boardId, $this->userId)); + } + + public function removeInactiveSessions(): int { + return $this->sessionMapper->deleteInactive(); + } + + public function notifyAllSessions(IQueue $queue, int $boardId, $event, $body, $causingSessionToken = null) { + $activeSessions = $this->sessionMapper->findAllActive($boardId); + $userIds = []; + foreach ($activeSessions as $session) { + if ($causingSessionToken && $session->getToken() === $causingSessionToken) { + // Limitation: + // If the same user has a second session active, the session $causingSessionToken + // still gets notified due to the current fact, that notify_push only supports + // to specify users, not individual sessions + // https://github.com/nextcloud/notify_push/issues/195 + continue; + } + + // don't notify the same user multiple times + if (!in_array($session->getUserId(), $userIds)) { + $userIds[] = $session->getUserId(); + } + } + + if ($causingSessionToken) { + // we only send a slice of the session token to everyone + // since knowledge of the full token allows everyone + // to close the session maliciously + $body['_causingSessionToken'] = substr($causingSessionToken, 0, 12); + } + foreach ($userIds as $userId) { + $queue->push('notify_custom', [ + 'user' => $userId, + 'message' => $event, + 'body' => $body + ]); + } + } +} diff --git a/package-lock.json b/package-lock.json index 13af3235f..71178385a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^1.6.0", "@nextcloud/moment": "^1.2.1", + "@nextcloud/notify_push": "^1.1.3", "@nextcloud/router": "^2.0.1", "@nextcloud/vue": "^7.3.0", "@nextcloud/vue-dashboard": "^2.0.1", @@ -3279,6 +3280,90 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/@nextcloud/notify_push": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.1.3.tgz", + "integrity": "sha512-dHu3mz2tcqFl43DBxRhbLRY5J8gGi/mwg9PgHbEtK9qDOZ4EFUUXDteWe+B4TCghDGh+xKS6U7oDC5txtF+JaQ==", + "dependencies": { + "@nextcloud/axios": "^1.11.0", + "@nextcloud/capabilities": "^1.0.4", + "@nextcloud/event-bus": "^3.0.2" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/auth": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-1.3.0.tgz", + "integrity": "sha512-GfwRM9W7hat4psNdAt74UHEV+drEXQ53klCVp6JpON66ZLPeK5eJ1LQuiQDkpUxZpqNeaumXjiB98h5cug/uQw==", + "dependencies": { + "@nextcloud/event-bus": "^1.1.3", + "@nextcloud/typings": "^0.2.2", + "core-js": "^3.6.4" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/auth/node_modules/@nextcloud/event-bus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.3.0.tgz", + "integrity": "sha512-+U5MnCvfnNWvf0lvdqJg8F+Nm8wN+s9ayuBjtiEQxTAcootv7lOnlMgfreqF3l2T0Wet2uZh4JbFVUWf8l3w7g==", + "dependencies": { + "@types/semver": "^7.3.5", + "core-js": "^3.11.2", + "semver": "^7.3.5" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/@nextcloud/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-NyaiSC2GX2CPaH/MUGGMTTTza/TW9ZqWNGWq6LJ+pLER8nqZ9BQkwJ5kXUYGo+i3cka68PO+9WhcDv4fSABpuQ==", + "dependencies": { + "@nextcloud/auth": "^1.3.0", + "axios": "^0.27.1", + "core-js": "^3.6.4" + }, + "engines": { + "node": "^16.0.0", + "npm": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/core-js": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz", + "integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nextcloud/notify_push/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/@nextcloud/router": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz", @@ -20968,6 +21053,76 @@ } } }, + "@nextcloud/notify_push": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nextcloud/notify_push/-/notify_push-1.1.3.tgz", + "integrity": "sha512-dHu3mz2tcqFl43DBxRhbLRY5J8gGi/mwg9PgHbEtK9qDOZ4EFUUXDteWe+B4TCghDGh+xKS6U7oDC5txtF+JaQ==", + "requires": { + "@nextcloud/axios": "^1.11.0", + "@nextcloud/capabilities": "^1.0.4", + "@nextcloud/event-bus": "^3.0.2" + }, + "dependencies": { + "@nextcloud/auth": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-1.3.0.tgz", + "integrity": "sha512-GfwRM9W7hat4psNdAt74UHEV+drEXQ53klCVp6JpON66ZLPeK5eJ1LQuiQDkpUxZpqNeaumXjiB98h5cug/uQw==", + "requires": { + "@nextcloud/event-bus": "^1.1.3", + "@nextcloud/typings": "^0.2.2", + "core-js": "^3.6.4" + }, + "dependencies": { + "@nextcloud/event-bus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.3.0.tgz", + "integrity": "sha512-+U5MnCvfnNWvf0lvdqJg8F+Nm8wN+s9ayuBjtiEQxTAcootv7lOnlMgfreqF3l2T0Wet2uZh4JbFVUWf8l3w7g==", + "requires": { + "@types/semver": "^7.3.5", + "core-js": "^3.11.2", + "semver": "^7.3.5" + } + } + } + }, + "@nextcloud/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-NyaiSC2GX2CPaH/MUGGMTTTza/TW9ZqWNGWq6LJ+pLER8nqZ9BQkwJ5kXUYGo+i3cka68PO+9WhcDv4fSABpuQ==", + "requires": { + "@nextcloud/auth": "^1.3.0", + "axios": "^0.27.1", + "core-js": "^3.6.4" + } + }, + "core-js": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz", + "integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "@nextcloud/router": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz", diff --git a/package.json b/package.json index f45c01dea..18e594456 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^1.6.0", "@nextcloud/moment": "^1.2.1", + "@nextcloud/notify_push": "^1.1.3", "@nextcloud/router": "^2.0.1", "@nextcloud/vue": "^7.3.0", "@nextcloud/vue-dashboard": "^2.0.1", diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 12c482bea..0ab662036 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -40,6 +40,8 @@

+