From e90d29555f1c45235afd02ac1f2ec99b15d06581 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 2 Oct 2023 23:20:47 +0200 Subject: [PATCH 1/6] revoke shares instead of deleting Signed-off-by: dartcafe --- appinfo/routes.php | 4 + lib/Controller/ShareController.php | 18 + lib/Db/Share.php | 30 ++ lib/Db/ShareMapper.php | 52 ++- lib/Event/ShareEvent.php | 1 + lib/Event/ShareRevokedEvent.php | 33 ++ lib/Exceptions/InvalidMethodCallException.php | 34 ++ lib/Migration/TableSchema.php | 4 + lib/Model/Acl.php | 354 +++++++++--------- lib/Service/PollService.php | 2 +- lib/Service/ShareService.php | 33 ++ src/js/Api/modules/shares.js | 16 + src/js/App.vue | 4 +- .../Actions/modules/ActionDelete.vue | 15 + src/js/components/Poll/PollHeaderButtons.vue | 2 +- src/js/components/Shares/ShareItem.vue | 32 +- src/js/components/Shares/SharesList.vue | 6 +- .../components/Shares/SharesListRevoked.vue | 68 ++++ src/js/components/SideBar/SideBarTabShare.vue | 3 + src/js/components/User/UserItem.vue | 8 +- src/js/store/modules/share.js | 1 + src/js/store/modules/shares.js | 38 +- src/js/store/modules/votes.js | 1 - 23 files changed, 563 insertions(+), 196 deletions(-) create mode 100644 lib/Event/ShareRevokedEvent.php create mode 100644 lib/Exceptions/InvalidMethodCallException.php create mode 100644 src/js/components/Shares/SharesListRevoked.vue diff --git a/appinfo/routes.php b/appinfo/routes.php index 86cfe5acd..86e393ad3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -98,6 +98,8 @@ return [ ['name' => 'share#admin_to_user', 'url' => '/share/{token}/user', 'verb' => 'PUT'], ['name' => 'share#set_label', 'url' => '/share/{token}/setlabel', 'verb' => 'PUT'], ['name' => 'share#set_public_poll_email', 'url' => '/share/{token}/publicpollemail/{value}', 'verb' => 'PUT'], + ['name' => 'share#revoke', 'url' => '/share/{token}/revoke', 'verb' => 'PUT'], + ['name' => 'share#re_revoke', 'url' => '/share/{token}/rerevoke', 'verb' => 'PUT'], ['name' => 'settings#getAppSettings', 'url' => '/settings/app', 'verb' => 'GET'], ['name' => 'settings#writeAppSettings', 'url' => '/settings/app', 'verb' => 'POST'], @@ -150,6 +152,8 @@ return [ ['name' => 'share_api#add', 'url' => '/api/v1.0/poll/{pollId}/share/{type}', 'verb' => 'POST'], ['name' => 'share_api#delete', 'url' => '/api/v1.0/share/{token}', 'verb' => 'DELETE'], ['name' => 'share_api#sendInvitation', 'url' => '/api/v1.0/share/send/{token}', 'verb' => 'PUT'], + ['name' => 'share_api#revoke', 'url' => '/api/v1.0/share/revoke/{token}', 'verb' => 'PUT'], + ['name' => 'share_api#re_revoke', 'url' => '/api/v1.0/share/rerevoke/{token}', 'verb' => 'PUT'], ['name' => 'subscription_api#get', 'url' => '/api/v1.0/poll/{pollId}/subscription', 'verb' => 'GET'], ['name' => 'subscription_api#subscribe', 'url' => '/api/v1.0/poll/{pollId}/subscription', 'verb' => 'PUT'], diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php index 1f5d62649..ff12770b3 100644 --- a/lib/Controller/ShareController.php +++ b/lib/Controller/ShareController.php @@ -113,6 +113,24 @@ class ShareController extends BaseController { return $this->responseDeleteTolerant(fn () => ['share' => $this->shareService->delete(token: $token)]); } + /** + * Delete share + * @NoAdminRequired + */ + + public function revoke(string $token): JSONResponse { + return $this->responseDeleteTolerant(fn () => ['share' => $this->shareService->revoke(token: $token)]); + } + + /** + * Delete share + * @NoAdminRequired + */ + + public function reRevoke(string $token): JSONResponse { + return $this->responseDeleteTolerant(fn () => ['share' => $this->shareService->reRevoke(token: $token)]); + } + /** * Send invitation mails for a share * Additionally send notification via notifications diff --git a/lib/Db/Share.php b/lib/Db/Share.php index a6a1c836d..89195fe03 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -47,10 +47,14 @@ use OCP\IURLGenerator; * @method void setInvitationSent(integer $value) * @method int getReminderSent() * @method void setReminderSent(integer $value) + * @method int getRevoked() + * @method void setRevoked(integer $value) * @method string getDisplayName() * @method void setDisplayName(string $value) * @method string getMiscSettings() * @method void setMiscSettings(string $value) + * @method int getVoted() + * @method void setVoted(int $value) */ class Share extends Entity implements JsonSerializable { public const TABLE = 'polls_share'; @@ -76,6 +80,23 @@ class Share extends Entity implements JsonSerializable { public const TYPE_CIRCLE = 'circle'; public const TYPE_CONTACTGROUP = 'contactGroup'; + + // Share types, that are allowed for public access (without login) + public const SHARE_PUBLIC_ACCESS_ALLOWED = [ + self::TYPE_PUBLIC, + self::TYPE_CONTACT, + self::TYPE_EMAIL, + self::TYPE_EXTERNAL, + ]; + + // Share types, that are allowed for authenticated access (with login) + public const SHARE_AUTH_ACCESS_ALLOWED = [ + self::TYPE_PUBLIC, + self::TYPE_ADMIN, + self::TYPE_GROUP, + self::TYPE_USER, + ]; + public const TYPE_SORT_ARRAY = [ self::TYPE_PUBLIC, self::TYPE_ADMIN, @@ -102,12 +123,15 @@ class Share extends Entity implements JsonSerializable { protected ?string $emailAddress = null; protected int $invitationSent = 0; protected int $reminderSent = 0; + protected int $revoked = 0; protected ?string $displayName = null; protected ?string $miscSettings = ''; + protected int $voted = 0; public function __construct() { $this->addType('pollId', 'int'); $this->addType('invitationSent', 'int'); + $this->addType('Revoked', 'int'); $this->addType('reminderSent', 'int'); $this->urlGenerator = Container::queryClass(IURLGenerator::class); $this->appSettings = new AppSettings; @@ -126,11 +150,13 @@ class Share extends Entity implements JsonSerializable { 'emailAddress' => $this->getEmailAddress(), 'invitationSent' => $this->getInvitationSent(), 'reminderSent' => $this->getReminderSent(), + 'revoked' => $this->getRevoked(), 'displayName' => $this->getDisplayName(), 'isNoUser' => !(in_array($this->getType(), [self::TYPE_USER, self::TYPE_ADMIN], true)), 'URL' => $this->getURL(), 'showLogin' => $this->appSettings->getBooleanSetting(AppSettings::SETTING_SHOW_LOGIN), 'publicPollEmail' => $this->getPublicPollEmail(), + 'voted' => $this->getVoted(), ]; } @@ -202,6 +228,10 @@ class Share extends Entity implements JsonSerializable { $this->setMiscSettings(json_encode($value)); } + private function getVoteCount(): int { + return 0; + } + private function getMiscSettingsArray(): array { if ($this->getMiscSettings()) { return json_decode($this->getMiscSettings(), true); diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php index 96c279979..cf438de9c 100644 --- a/lib/Db/ShareMapper.php +++ b/lib/Db/ShareMapper.php @@ -25,10 +25,12 @@ namespace OCA\Polls\Db; use OCA\Polls\Exceptions\ShareNotFoundException; +use OCA\Polls\Migration\TableSchema; use OCA\Polls\Model\UserBase; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; use OCP\IDBConnection; /** @@ -37,7 +39,10 @@ use OCP\IDBConnection; class ShareMapper extends QBMapper { public const TABLE = Share::TABLE; - public function __construct(IDBConnection $db) { + public function __construct( + IDBConnection $db, + private IConfig $config, + ) { parent::__construct($db, Share::TABLE, Share::class); } @@ -47,16 +52,41 @@ class ShareMapper extends QBMapper { * @psalm-return array */ public function findByPoll(int $pollId): array { + $qb = $this->db->getQueryBuilder(); - $qb->select('*') - ->from($this->getTableName()) + // get all column names from TableSchema + foreach (TableSchema::TABLES[Share::TABLE] as $column => $values) { + $selectColumns[] = 'p.' . $column; + } + // add vote counter + $selectColumns[] = $qb->func()->count('c1.id', 'voted'); + + // Build the following select (MySQL) + // + // SELECT p.*, COUNT(c1.user_id) as voted + // FROM oc_polls_share p + // LEFT JOIN oc_polls_votes c1 + // ON p.poll_id = c1.poll_id AND + // p.user_id = c1.user_id + // GROUP BY p.poll_id, p.user_id + // + $qb->select($selectColumns) + ->from($this->getTableName(), 'p') ->where( - $qb->expr()->eq('poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)) - ); + $qb->expr()->eq('p.poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)) + ) + ->leftJoin('p', VOTE::TABLE, 'c1', + $qb->expr()->andX( + $qb->expr()->eq('p.poll_id', 'c1.poll_id'), + $qb->expr()->eq('p.user_id', 'c1.user_id'), + ) + ) + ->groupBy('poll_id', 'user_id'); return $this->findEntities($qb); } + /** * @throws \OCP\AppFramework\Db\DoesNotExistException if not found * @return Share[] @@ -74,6 +104,18 @@ class ShareMapper extends QBMapper { return $this->findEntities($qb); } + + public function countVotesByShare(Share $share): int { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->config->getSystemValue('dbtableprefix', 'oc_') . Vote::TABLE) + ->where($qb->expr()->eq('poll_id', $qb->createNamedParameter($share->getPollId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($share->getUserId(), IQueryBuilder::PARAM_STR))); + + return count($this->findEntities($qb)); + } + /** * @throws \OCP\AppFramework\Db\DoesNotExistException if not found * @return Share[] diff --git a/lib/Event/ShareEvent.php b/lib/Event/ShareEvent.php index d2c175972..bc1f71ef9 100644 --- a/lib/Event/ShareEvent.php +++ b/lib/Event/ShareEvent.php @@ -35,6 +35,7 @@ abstract class ShareEvent extends BaseEvent { public const CHANGE_REG_CONSTR = 'share_change_reg_const'; public const REGISTRATION = 'share_registration'; public const DELETE = 'share_delete'; + public const REVOKED = 'share_revoked'; private Share $share; // protected UserBase $sharee = null; diff --git a/lib/Event/ShareRevokedEvent.php b/lib/Event/ShareRevokedEvent.php new file mode 100644 index 000000000..465dc52e6 --- /dev/null +++ b/lib/Event/ShareRevokedEvent.php @@ -0,0 +1,33 @@ + + * + * @author René Gieling + * + * @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 . + * + */ + +namespace OCA\Polls\Event; + +use OCA\Polls\Db\Share; + +class ShareRevokedEvent extends ShareEvent { + public function __construct(Share $share) { + parent::__construct($share); + $this->eventId = self::REVOKED; + } +} diff --git a/lib/Exceptions/InvalidMethodCallException.php b/lib/Exceptions/InvalidMethodCallException.php new file mode 100644 index 000000000..38cf4de5d --- /dev/null +++ b/lib/Exceptions/InvalidMethodCallException.php @@ -0,0 +1,34 @@ + + * + * @author René Gieling + * + * @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 . + * + */ + +namespace OCA\Polls\Exceptions; + +use OCP\AppFramework\Http; + +class InvalidMethodCallException extends Exception { + public function __construct( + string $e = 'Method is used illegally' + ) { + parent::__construct($e, Http::STATUS_CONFLICT); + } +} diff --git a/lib/Migration/TableSchema.php b/lib/Migration/TableSchema.php index 3b6b5137b..1e66423ce 100644 --- a/lib/Migration/TableSchema.php +++ b/lib/Migration/TableSchema.php @@ -151,6 +151,9 @@ abstract class TableSchema { /** * define table structure + * + * IMPORTANT: After adding or deletion check queries in ShareMapper + * */ public const TABLES = [ Poll::TABLE => [ @@ -215,6 +218,7 @@ abstract class TableSchema { 'display_name' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 256]], 'email_address' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 256]], 'invitation_sent' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'revoked' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'reminder_sent' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'misc_settings' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 65535]], ], diff --git a/lib/Model/Acl.php b/lib/Model/Acl.php index 1163bde50..37004f1db 100644 --- a/lib/Model/Acl.php +++ b/lib/Model/Acl.php @@ -32,6 +32,7 @@ use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; use OCA\Polls\Db\VoteMapper; use OCA\Polls\Exceptions\ForbiddenException; +use OCA\Polls\Exceptions\InvalidMethodCallException; use OCA\Polls\Exceptions\NotFoundException; use OCA\Polls\Exceptions\ShareNotFoundException; use OCA\Polls\Model\Settings\AppSettings; @@ -65,8 +66,8 @@ class Acl implements JsonSerializable { public const PERMISSION_ALL_ACCESS = 'allAccess'; private AppSettings $appSettings; - private Poll $poll; - private Share $share; + private ?Poll $poll; + private ?Share $share; public function __construct( private IUserManager $userManager, @@ -77,50 +78,115 @@ class Acl implements JsonSerializable { private VoteMapper $voteMapper, private ShareMapper $shareMapper ) { - $this->poll = new Poll; - $this->share = new Share; + $this->poll = null; + $this->share = null; $this->appSettings = new AppSettings; } + public function jsonSerialize(): array { + return [ + 'allowAddOptions' => $this->getIsAllowed(self::PERMISSION_OPTIONS_ADD), + 'allowAllAccess' => $this->getIsAllowed(self::PERMISSION_ALL_ACCESS), + 'allowArchive' => $this->getIsAllowed(self::PERMISSION_POLL_ARCHIVE), + 'allowComment' => $this->getIsAllowed(self::PERMISSION_COMMENT_ADD), + 'allowDelete' => $this->getIsAllowed(self::PERMISSION_POLL_DELETE), + 'allowEdit' => $this->getIsAllowed(self::PERMISSION_POLL_EDIT), + 'allowPollCreation' => $this->getIsAllowed(self::PERMISSION_POLL_CREATE), + 'allowPollDownload' => $this->getIsAllowed(self::PERMISSION_POLL_DOWNLOAD), + 'allowPublicShares' => $this->getIsAllowed(self::PERMISSION_PUBLIC_SHARES), + 'allowSeeResults' => $this->getIsAllowed(self::PERMISSION_POLL_RESULTS_VIEW), + 'allowSeeUsernames' => $this->getIsAllowed(self::PERMISSION_POLL_USERNAMES_VIEW), + 'allowSeeMailAddresses' => $this->getIsAllowed(self::PERMISSION_POLL_MAILADDRESSES_VIEW), + 'allowSubscribe' => $this->getIsAllowed(self::PERMISSION_POLL_SUBSCRIBE), + 'allowView' => $this->getIsAllowed(self::PERMISSION_POLL_VIEW), + 'allowVote' => $this->getIsAllowed(self::PERMISSION_VOTE_EDIT), + 'displayName' => $this->getDisplayName(), + 'isOwner' => $this->getIsOwner(), + 'isVoteLimitExceeded' => $this->getIsVoteLimitExceeded(), + 'loggedIn' => $this->getIsLoggedIn(), + 'isNoUser' => !$this->getIsLoggedIn(), + 'isGuest' => !$this->getIsLoggedIn(), + 'pollId' => $this->getPollId(), + 'token' => $this->getToken(), + 'userHasVoted' => $this->getIsParticipant(), + 'userId' => $this->getUserId(), + 'userIsInvolved' => $this->getIsInvolved(), + 'pollExpired' => $this->poll->getExpired(), + 'pollExpire' => $this->poll->getExpire(), + ]; + } + /** - * load share via token and than call setShare + * Setters */ - public function setToken(string $token = '', - string $permission = self::PERMISSION_POLL_VIEW, - ?int $pollIdToValidate = null - ): Acl { + + /** + * Set share token and load share if neccessary + * All ends with self::setpoll(), where the permission is checked + */ + public function setToken(string $token = '', string $permission = self::PERMISSION_POLL_VIEW): Acl { try { - $this->share = $this->shareMapper->findByToken($token); - - if ($pollIdToValidate && $this->share->getPollId() !== $pollIdToValidate) { - throw new ForbiddenException('Share is not allowed accessing this poll'); + if ($this->share?->$token === $token) { // share matching the requested token is already loaded + $this->setPollId($this->share->getPollId(), $permission); // Set the poll Id to verify the correct poll gets loaded and permissions get checked + } else { + $this->setShare($this->shareMapper->findByToken($token), $permission); // load the share mathing the requested token } - - $this->poll = $this->pollMapper->find($this->share->getPollId()); - $this->validateShareAccess(); - $this->request($permission); } catch (ShareNotFoundException $e) { throw new NotFoundException('Error loading share ' . $token); } + + return $this; + } + + /** + * Set share and load poll + * All ends with self::setPoll(), where the permission is checked + */ + public function setShare(Share $share, string $permission = self::PERMISSION_POLL_VIEW): Acl { + $this->share = $share; + + if ($this->share->getRevoked()) { + throw new ShareNotFoundException(); + } + + $this->validateShareAccess(); // check, if share is allowed for the user type + $this->setPollId($this->share->getPollId(), $permission); // set the poll id to laod the poll corresponding to the share and check permissions return $this; } + /** + * Set poll id and load poll + */ public function setPollId(int $pollId = 0, string $permission = self::PERMISSION_POLL_VIEW): Acl { try { - $this->poll = $this->pollMapper->find($pollId); - $this->request($permission); + if ($this->poll?->getPollId() !== $pollId) { + $this->setPoll($this->pollMapper->find($pollId), $permission); // load requested poll + } else { + $this->request($permission); // just check the permissions in all cases + } + } catch (DoesNotExistException $e) { throw new NotFoundException('Error loading poll with id ' . $pollId); } - + return $this; } - public function setPoll(Poll $poll): void { + /** + * Set poll + */ + public function setPoll(Poll $poll, string $permission = self::PERMISSION_POLL_VIEW): Acl { $this->poll = $poll; + $this->loadShare(); + $this->request($permission); + return $this; } + /** + * Property getters + */ + public function getPoll(): Poll { return $this->poll; } @@ -130,13 +196,29 @@ class Acl implements JsonSerializable { } public function getToken(): string { - return strval($this->share->getToken()); + return strval($this->share?->getToken()); + } + + public function getTokenIsValid(): bool { + return ($this->share?->getToken() && !$this->share?->getRevoked()); } public function getUserId(): string { return $this->userSession->getUser()?->getUID() ?? $this->share->getUserId(); } + private function getDisplayName(): string { + return ($this->getIsLoggedIn() ? $this->userManager->get($this->getUserId())?->getDisplayName() : $this->share->getDisplayName()) ?? ''; + } + + /** + * Validations + */ + + public function getIsOwner(): bool { + return ($this->getIsLoggedIn() && $this->poll->getOwner() === $this->getUserId()); + } + public function validateUserId(string $userId): void { if ($this->getUserId() !== $userId) { throw new ForbiddenException('User id does not match.'); @@ -149,12 +231,13 @@ class Acl implements JsonSerializable { } } - public function getIsOwner(): bool { - return ($this->getIsLoggedIn() && $this->poll->getOwner() === $this->getUserId()); - } - - private function getDisplayName(): string { - return ($this->getIsLoggedIn() ? $this->userManager->get($this->getUserId())?->getDisplayName() : $this->share->getDisplayName()) ?? ''; + private function validateShareAccess(): void { + if ($this->getIsLoggedIn() && !$this->getIsShareValidForUsers()) { + throw new ForbiddenException('Share type "' . $this->share->getType() . '" is only valid for guests'); + } + if (!$this->getIsShareValidForGuests()) { + throw new ForbiddenException('Share type "' . $this->share->getType() . '" is only valid for registered users'); + }; } public function getIsAllowed(string $permission): bool { @@ -212,39 +295,6 @@ class Acl implements JsonSerializable { return false; } - public function jsonSerialize(): array { - return [ - 'allowAddOptions' => $this->getIsAllowed(self::PERMISSION_OPTIONS_ADD), - 'allowAllAccess' => $this->getIsAllowed(self::PERMISSION_ALL_ACCESS), - 'allowArchive' => $this->getIsAllowed(self::PERMISSION_POLL_ARCHIVE), - 'allowComment' => $this->getIsAllowed(self::PERMISSION_COMMENT_ADD), - 'allowDelete' => $this->getIsAllowed(self::PERMISSION_POLL_DELETE), - 'allowEdit' => $this->getIsAllowed(self::PERMISSION_POLL_EDIT), - 'allowPollCreation' => $this->getIsAllowed(self::PERMISSION_POLL_CREATE), - 'allowPollDownload' => $this->getIsAllowed(self::PERMISSION_POLL_DOWNLOAD), - 'allowPublicShares' => $this->getIsAllowed(self::PERMISSION_PUBLIC_SHARES), - 'allowSeeResults' => $this->getIsAllowed(self::PERMISSION_POLL_RESULTS_VIEW), - 'allowSeeUsernames' => $this->getIsAllowed(self::PERMISSION_POLL_USERNAMES_VIEW), - 'allowSeeMailAddresses' => $this->getIsAllowed(self::PERMISSION_POLL_MAILADDRESSES_VIEW), - 'allowSubscribe' => $this->getIsAllowed(self::PERMISSION_POLL_SUBSCRIBE), - 'allowView' => $this->getIsAllowed(self::PERMISSION_POLL_VIEW), - 'allowVote' => $this->getIsAllowed(self::PERMISSION_VOTE_EDIT), - 'displayName' => $this->getDisplayName(), - 'isOwner' => $this->getIsOwner(), - 'isVoteLimitExceeded' => $this->getIsVoteLimitExceeded(), - 'loggedIn' => $this->getIsLoggedIn(), - 'isNoUser' => !$this->getIsLoggedIn(), - 'isGuest' => !$this->getIsLoggedIn(), - 'pollId' => $this->getPollId(), - 'token' => $this->getToken(), - 'userHasVoted' => $this->getIsParticipant(), - 'userId' => $this->getUserId(), - 'userIsInvolved' => $this->getIsInvolved(), - 'pollExpired' => $this->poll->getExpired(), - 'pollExpire' => $this->poll->getExpire(), - ]; - } - /** * getIsLogged - Is user logged in to nextcloud? */ @@ -306,23 +356,16 @@ class Acl implements JsonSerializable { * This only affects logged in users. */ private function getIsPersonallyInvited(): bool { - if (!$this->getIsLoggedIn()) { - return false; + if ($this->getIsLoggedIn() && $this->share) { + return in_array($this->share->getType(), [ + Share::TYPE_ADMIN, + Share::TYPE_USER, + Share::TYPE_EXTERNAL, + Share::TYPE_EMAIL, + Share::TYPE_CONTACT + ]); } - - return 0 < count( - array_filter($this->shareMapper->findByPoll($this->getPollId()), function ($item) { - return ($item->getUserId() === $this->getUserId() - && in_array($item->getType(), [ - Share::TYPE_ADMIN, - Share::TYPE_USER, - Share::TYPE_EXTERNAL, - Share::TYPE_EMAIL, - Share::TYPE_CONTACT - ]) - ); - }) - ); + return false; } private function getIsDelegatedAdmin(): bool { @@ -330,100 +373,84 @@ class Acl implements JsonSerializable { return false; } - $filteredList = array_filter($this->shareMapper->findByPoll($this->getPollId()), function ($item) { - return ($item->getUserId() === $this->getUserId() - && in_array($item->getType(), [ - Share::TYPE_ADMIN, - ]) - ); - }); - - return 0 < count($filteredList); - } - - private function validateShareAccess(): void { - if ($this->getIsLoggedIn() && !$this->getIsShareValidForUsers()) { - throw new ForbiddenException('Share type "' . $this->share->getType() . '" is only valid for guests'); - } - if (!$this->getIsShareValidForGuests()) { - throw new ForbiddenException('Share type "' . $this->share->getType() . '" is only valid for registered users'); + if ($this->loadShare()) { // load share, if not loaded + return $this->share->getType() === Share::TYPE_ADMIN && !$this->share->getRevoked(); }; + return false; } private function getIsShareValidForGuests(): bool { - return in_array($this->share->getType(), [ - Share::TYPE_PUBLIC, - Share::TYPE_EMAIL, - Share::TYPE_CONTACT, - Share::TYPE_EXTERNAL - ]); + return in_array($this->share->getType(), Share::SHARE_PUBLIC_ACCESS_ALLOWED); } private function getIsShareValidForUsers(): bool { - return in_array($this->share->getType(), [ - Share::TYPE_PUBLIC, - Share::TYPE_ADMIN, - Share::TYPE_USER, - Share::TYPE_GROUP - ]); + return in_array($this->share->getType(), Share::SHARE_AUTH_ACCESS_ALLOWED); } private function getHasEmail(): bool { - return $this->share->getToken() ? strlen($this->share->getEmailAddress()) > 0 : $this->getIsLoggedIn(); + return $this->share?->getToken() ? strlen($this->share->getEmailAddress()) > 0 : $this->getIsLoggedIn(); + } + + /** + * Load share for access checks, if it is not already loaded + **/ + private function loadShare(): bool { + if (!$this->poll) { + throw new InvalidMethodCallException('Loading share only possible with loaded poll'); + } + + try { + if ($this->share?->getUserId() !== $this->getUserId() || $this->share?->getPollId() !== $this->poll?->getId()) { + $this->share = $this->shareMapper->findByPollAndUser($this->poll->getId(), $this->getUserId()); + } + } catch (\Throwable $th) { + $this->share = null; + return false; + } + + return $this->share !== null; } /** * Checks, if user is allowed to edit the poll configuration **/ private function getAllowEditPoll(): bool { - // Console god mode if (defined('OC_CONSOLE')) { - return true; + return true; // Console god mode } - // owner is always allowed to edit the poll configuration if ($this->getIsOwner()) { - return true; + return true; // owner is always allowed to edit the poll configuration } - // user has delegated owner rights if ($this->getIsDelegatedAdmin()) { - return true; + return true; // user has delegated owner rights } - return false; + return false; // deny edit rights in all other cases } /** * Checks, if user is allowed to access poll **/ private function getAllowAccessPoll(): bool { - // edit rights include access to poll if ($this->getAllowEditPoll()) { - return true; + return true; // edit rights include access to poll } - // No further access to poll, if it is deleted if ($this->poll->getDeleted()) { - return false; + return false; // No further access to poll, if it is deleted } - // grant access if user is involved in poll in any way if ($this->getIsInvolved()) { - return true; + return true; // grant access if user is involved in poll in any way } - - // grant access if poll poll is an open poll (for logged in users) + if ($this->poll->getAccess() === Poll::ACCESS_OPEN && $this->getIsLoggedIn()) { - return true; + return true; // grant access if poll poll is an open poll (for logged in users) } - // user has valid token of this poll - if ($this->getToken()) { - return true; - } - - return false; + return $this->getTokenIsValid(); // return check result of an existing valid share for this user } /** @@ -431,62 +458,54 @@ class Acl implements JsonSerializable { * includes the right to archive and take over **/ private function getAllowDeletePoll(): bool { - // users with edit rights are allowed to delete the poll if ($this->getAllowEditPoll()) { - return true; + return true; // users with edit rights are allowed to delete the poll } - // admins are allowed to delete the poll if ($this->getIsAdmin()) { - return true; + return true; // admins are allowed to delete polls } - return false; + return false; // in all other cases deny poll deletion right } - /** - * User rights inside poll - **/ - /** * Checks, if user is allowed to add add vote options **/ private function getAllowAddOptions(): bool { - // Edit right includes adding new options if ($this->getAllowEditPoll()) { - return true; + return true; // Edit right includes adding new options } - // deny, if user has no access right to this poll if (!$this->getAllowAccessPoll()) { - return false; + return false; // deny, if user has no access right to this poll } - // public shares are not allowed to add options - if ($this->share->getType() === Share::TYPE_PUBLIC) { - return false; + if ($this->share?->getType() === Share::TYPE_PUBLIC) { + return false; // public shares are not allowed to add options } - // Request for option proposals is expired, deny if ($this->poll->getProposalsExpired()) { - return false; + return false; // Request for option proposals is expired, deny } - return $this->poll->getAllowProposals() === Poll::PROPOSAL_ALLOW; + return $this->poll->getAllowProposals() === Poll::PROPOSAL_ALLOW; // Allow, if poll requests proposals } /** - * Checks, if user is allowed to comment + * Checks, if user is allowed to see and write comments **/ private function getAllowComment(): bool { - // user has no access right to this poll if (!$this->getAllowAccessPoll()) { - return false; + return false; // user has no access right to this poll } - // public shares are not allowed to comment - if ($this->share->getType() === Share::TYPE_PUBLIC) { - return false; + if ($this->share?->getType() === Share::TYPE_PUBLIC) { + return false; // public shares are not allowed to comment + } + + if ($this->share?->getRevoked()) { + return false; // public shares are not allowed to comment } return (bool) $this->poll->getAllowComment(); @@ -496,43 +515,40 @@ class Acl implements JsonSerializable { * Checks, if user is allowed to comment **/ private function getAllowVote(): bool { - // user has no access right to this poll if (!$this->getAllowAccessPoll()) { - return false; + return false; // user has no access right to this poll } - // public shares are not allowed to vote - if ($this->share->getType() === Share::TYPE_PUBLIC) { - return false; + if ($this->share?->getType() === Share::TYPE_PUBLIC) { + return false; // public shares are not allowed to vote } - // deny votes, if poll is expired - return !$this->poll->getExpired(); + if ($this->share?->getRevoked()) { + return false; // public shares are not allowed to vote + } + + return !$this->poll->getExpired(); // deny votes, if poll is expired } private function getAllowSubscribeToPoll(): bool { - // user has no access right to this poll if (!$this->getAllowAccessPoll()) { - return false; + return false; // user has no access right to this poll } return $this->getHasEmail(); } private function getShowResults(): bool { - // edit rights include access to results if ($this->getAllowEditPoll()) { - return true; + return true; // edit rights include access to results } - // no access to poll, deny if (!$this->getAllowAccessPoll()) { - return false; + return false; // no access to poll, deny } - // show results, when poll is cloed if ($this->poll->getShowResults() === Poll::SHOW_RESULTS_CLOSED && $this->poll->getExpired()) { - return true; + return true; // show results, when poll is cloed } return $this->poll->getShowResults() === Poll::SHOW_RESULTS_ALWAYS; diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index bca7fb909..9c557ab17 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -83,7 +83,7 @@ class PollService { foreach ($polls as $poll) { try { - $this->acl->setPollId($poll->getId()); + $this->acl->setPoll($poll); // TODO: Not the elegant way. Improvement neccessary $relevantThreshold = max( $poll->getCreated(), diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index b0160b69f..d5a14418b 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -32,6 +32,7 @@ use OCA\Polls\Event\ShareChangedRegistrationConstraintEvent; use OCA\Polls\Event\ShareCreateEvent; use OCA\Polls\Event\ShareDeletedEvent; use OCA\Polls\Event\ShareRegistrationEvent; +use OCA\Polls\Event\ShareRevokedEvent; use OCA\Polls\Event\ShareTypeChangedEvent; use OCA\Polls\Exceptions\ForbiddenException; use OCA\Polls\Exceptions\InvalidShareTypeException; @@ -325,6 +326,38 @@ class ShareService { return $share->getToken(); } + /** + * Revoke share + */ + public function revoke(Share $share = null, string $token = null): string { + if ($token) { + $share = $this->shareMapper->findByToken($token); + } + $this->acl->setPollId($share->getPollId(), Acl::PERMISSION_POLL_EDIT); + + $share->setRevoked(time()); + $this->shareMapper->update($share); + $this->eventDispatcher->dispatchTyped(new ShareRevokedEvent($share)); + + return $share->getToken(); + } + + /** + * Re-revoke share + */ + public function reRevoke(Share $share = null, string $token = null): string { + if ($token) { + $share = $this->shareMapper->findByToken($token); + } + $this->acl->setPollId($share->getPollId(), Acl::PERMISSION_POLL_EDIT); + + $share->setRevoked(0); + $this->shareMapper->update($share); + $this->eventDispatcher->dispatchTyped(new ShareCreateEvent($share)); + + return $share->getToken(); + } + public function sendAllInvitations(int $pollId): SentResult|null { $sentResult = new SentResult(); diff --git a/src/js/Api/modules/shares.js b/src/js/Api/modules/shares.js index d70da75a8..b45c1015d 100644 --- a/src/js/Api/modules/shares.js +++ b/src/js/Api/modules/shares.js @@ -95,6 +95,22 @@ const shares = { }) }, + revokeShare(shareToken) { + return httpInstance.request({ + method: 'PUT', + url: `share/${shareToken}/revoke`, + cancelToken: cancelTokenHandlerObject[this.revokeShare.name].handleRequestCancellation().token, + }) + }, + + reRevokeShare(shareToken) { + return httpInstance.request({ + method: 'PUT', + url: `share/${shareToken}/rerevoke`, + cancelToken: cancelTokenHandlerObject[this.reRevokeShare.name].handleRequestCancellation().token, + }) + }, + inviteAll(pollId) { return httpInstance.request({ method: 'PUT', diff --git a/src/js/App.vue b/src/js/App.vue index f10d80af0..2d41be4ec 100644 --- a/src/js/App.vue +++ b/src/js/App.vue @@ -24,7 +24,7 @@ - + @@ -120,7 +120,7 @@ export default { }) subscribe('polls:sidebar:toggle', (payload) => { - emit('polls:sidebar:changeTab', { activeTab: payload.activeTab }) + emit('polls:sidebar:changeTab', { activeTab: payload?.activeTab }) this.sideBar.open = payload?.open ?? !this.sideBar.open }) }, diff --git a/src/js/components/Actions/modules/ActionDelete.vue b/src/js/components/Actions/modules/ActionDelete.vue index 2406fb2d7..bf6c914ff 100644 --- a/src/js/components/Actions/modules/ActionDelete.vue +++ b/src/js/components/Actions/modules/ActionDelete.vue @@ -29,6 +29,9 @@ + @@ -40,12 +43,14 @@ diff --git a/src/js/components/SideBar/SideBarTabShare.vue b/src/js/components/SideBar/SideBarTabShare.vue index e8e3133bf..ccf3b0e13 100644 --- a/src/js/components/SideBar/SideBarTabShare.vue +++ b/src/js/components/SideBar/SideBarTabShare.vue @@ -24,6 +24,7 @@ @@ -31,6 +32,7 @@ import { mapState } from 'vuex' import SharesList from '../Shares/SharesList.vue' import SharesListUnsent from '../Shares/SharesListUnsent.vue' +import SharesListRevoked from '../Shares/SharesListRevoked.vue' export default { name: 'SideBarTabShare', @@ -38,6 +40,7 @@ export default { components: { SharesList, SharesListUnsent, + SharesListRevoked, }, computed: { diff --git a/src/js/components/User/UserItem.vue b/src/js/components/User/UserItem.vue index 9ba862858..7b32fb88d 100644 --- a/src/js/components/User/UserItem.vue +++ b/src/js/components/User/UserItem.vue @@ -31,7 +31,8 @@ :show-user-status="showUserStatus" :user="avatarUserId" :display-name="name" - :is-no-user="isNoUser"> + :is-no-user="isNoUser" + @click="showMenu()">