Merge pull request #3096 from nextcloud/enh/revoke-shares

revoke shares instead of deleting
This commit is contained in:
René Gieling 2023-10-03 14:50:58 +02:00 коммит произвёл GitHub
Родитель 5c11ffd15f c10d17d0cc
Коммит db9295effc
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
25 изменённых файлов: 610 добавлений и 209 удалений

Просмотреть файл

@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
### New
- Reveal hidden voters if hidden in case of performance concerns
- Support better readability of vote page
- added revoking of shares
- changed the behavior of share deletion
### Changes
- Improved username check for public polls with a large number of groups in the backend
## [5.3.2] - 2023-09-11

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@ -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<array-key, Share>
*/
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[]

Просмотреть файл

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

Просмотреть файл

@ -0,0 +1,33 @@
<?php
/*
* @copyright Copyright (c) 2021 René Gieling <github@dartcafe.de>
*
* @author René Gieling <github@dartcafe.de>
*
* @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\Polls\Event;
use OCA\Polls\Db\Share;
class ShareRevokedEvent extends ShareEvent {
public function __construct(Share $share) {
parent::__construct($share);
$this->eventId = self::REVOKED;
}
}

Просмотреть файл

@ -0,0 +1,34 @@
<?php
/**
* @copyright Copyright (c) 2020 René Gieling <github@dartcafe.de>
*
* @author René Gieling <github@dartcafe.de>
*
* @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\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);
}
}

Просмотреть файл

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

Просмотреть файл

@ -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;
@ -64,10 +65,7 @@ class Acl implements JsonSerializable {
public const PERMISSION_PUBLIC_SHARES = 'publicShares';
public const PERMISSION_ALL_ACCESS = 'allAccess';
private AppSettings $appSettings;
private Poll $poll;
private Share $share;
public function __construct(
private IUserManager $userManager,
private IUserSession $userSession,
@ -75,53 +73,120 @@ class Acl implements JsonSerializable {
private OptionMapper $optionMapper,
private PollMapper $pollMapper,
private VoteMapper $voteMapper,
private ShareMapper $shareMapper
private ShareMapper $shareMapper,
private AppSettings $appSettings,
private ?Poll $poll,
private ?Share $share,
) {
$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;
}
public function getPoll(): Poll {
/**
* Property getters
*/
public function getPoll(): Poll|null {
return $this->poll;
}
@ -130,13 +195,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 +230,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 +294,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 +355,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 +372,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 +457,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 +514,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;

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@ -24,7 +24,7 @@
<NcContent app-name="polls" :class="appClass">
<router-view v-if="getCurrentUser()" name="navigation" />
<router-view />
<router-view v-show="sideBar.open" name="sidebar" />
<router-view v-if="poll.acl.allowEdit || poll.acl.allowComment" name="sidebar" />
<LoadingOverlay v-if="loading" />
<UserSettingsDlg />
</NcContent>
@ -34,7 +34,7 @@
import UserSettingsDlg from './components/Settings/UserSettingsDlg.vue'
import { getCurrentUser } from '@nextcloud/auth'
import { NcContent } from '@nextcloud/vue'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { mapState, mapActions } from 'vuex'
import '@nextcloud/dialogs/dist/index.css'
import './assets/scss/colors.scss'
@ -57,9 +57,6 @@ export default {
data() {
return {
sideBar: {
open: (window.innerWidth > 920),
},
transitionClass: 'transitions-active',
loading: false,
isLoggedin: !!getCurrentUser(),
@ -119,16 +116,11 @@ export default {
this.loadPoll(silent)
})
subscribe('polls:sidebar:toggle', (payload) => {
emit('polls:sidebar:changeTab', { activeTab: payload.activeTab })
this.sideBar.open = payload?.open ?? !this.sideBar.open
})
},
beforeDestroy() {
this.cancelToken.cancel()
unsubscribe('polls:poll:load')
unsubscribe('polls:sidebar:toggle')
unsubscribe('polls:transitions:on')
unsubscribe('polls:transitions:off')
},

Просмотреть файл

@ -29,6 +29,9 @@
<UndoIcon v-if="deleteTimeout"
:size="iconSize"
@click="cancelDelete()" />
<RevokeIcon v-else-if="revoke"
:size="iconSize"
@click="deleteItem()" />
<DeleteIcon v-else
:size="iconSize"
@click="deleteItem()" />
@ -40,12 +43,14 @@
<script>
import { NcButton } from '@nextcloud/vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import RevokeIcon from 'vue-material-design-icons/Close.vue'
import UndoIcon from 'vue-material-design-icons/ArrowULeftTop.vue'
export default {
name: 'ActionDelete',
components: {
DeleteIcon,
RevokeIcon,
UndoIcon,
NcButton,
},
@ -64,6 +69,10 @@ export default {
type: Number,
default: 20,
},
revoke: {
type: Boolean,
default: false,
},
},
data() {
@ -85,6 +94,12 @@ export default {
methods: {
deleteItem() {
// delete immediately
if (this.timeout === 0) {
this.$emit('delete')
return
}
this.countDown = this.timeout
this.deleteInterval = setInterval(() => {
this.countdown -= 1

Просмотреть файл

@ -66,7 +66,7 @@ export default {
computed: {
...mapState({
allowComment: (state) => state.poll.allowComment,
allowComment: (state) => state.poll.acl.allowComment,
allowEdit: (state) => state.poll.acl.allowEdit,
allowVote: (state) => state.poll.acl.allowVote,
allowPollDownload: (state) => state.poll.acl.allowPollDownload,

Просмотреть файл

@ -24,9 +24,10 @@
<UserItem v-bind="share"
show-email
resolve-info
:forced-description="share.revoked ? t('polls', 'User is only able to see the votes.') : null"
:icon="true">
<template #status>
<div v-if="hasVoted(share.userId)">
<div v-if="share.voted">
<VotedIcon class="vote-status voted" :title="t('polls', 'Has voted')" />
</div>
<div v-else-if="['public', 'group'].includes(share.type)">
@ -37,7 +38,7 @@
</div>
</template>
<NcActions>
<NcActions v-if="!share.revoked">
<NcActionInput v-if="share.type === 'public'"
:show-trailing-button="false"
:value.sync="label"
@ -112,13 +113,24 @@
</NcActionRadio>
</NcActions>
<ActionDelete :title="t('polls', 'Remove share')"
@delete="removeShare({ share })" />
<NcActions v-if="share.revoked">
<NcActionButton @click="reRevokeShare({ share })">
<template #icon>
<ReRevokeIcon />
</template>
{{ t('polls', 'Re-Revoke share') }}
</NcActionButton>
</NcActions>
<ActionDelete :timeout="share.revoked ? 4 : 0"
:revoke="!!share.voted && !share.revoked"
:title="deleteButtonCaption"
@delete="clickDeleted(share)" />
</UserItem>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import { mapActions } from 'vuex'
import { showSuccess, showError } from '@nextcloud/dialogs'
import { NcActions, NcActionButton, NcActionCaption, NcActionInput, NcActionRadio } from '@nextcloud/vue'
import { ActionDelete } from '../Actions/index.js'
@ -131,6 +143,7 @@ import EditIcon from 'vue-material-design-icons/Pencil.vue'
import WithdrawAdminIcon from 'vue-material-design-icons/ShieldCrownOutline.vue'
import ClippyIcon from 'vue-material-design-icons/ClipboardArrowLeftOutline.vue'
import QrIcon from 'vue-material-design-icons/Qrcode.vue'
import ReRevokeIcon from 'vue-material-design-icons/Recycle.vue'
export default {
name: 'ShareItem',
@ -151,6 +164,7 @@ export default {
NcActionRadio,
ActionDelete,
ResolveGroupIcon,
ReRevokeIcon,
},
props: {
@ -161,10 +175,6 @@ export default {
},
computed: {
...mapGetters({
hasVoted: 'votes/hasVoted',
}),
label: {
get() {
return this.share.displayName
@ -173,17 +183,50 @@ export default {
this.$store.commit('shares/setShareProperty', { id: this.share.id, displayName: value })
},
},
deleteButtonCaption() {
if (this.share.voted && this.share.revoked) {
return t('polls', 'Delete share and remove user from poll')
}
if (this.share.voted && !this.share.revoked) {
return t('polls', 'Revoke share')
}
return t('polls', 'Delete share')
},
},
methods: {
...mapActions({
removeShare: 'shares/delete',
deleteShare: 'shares/delete',
revokeShare: 'shares/revoke',
reRevokeShare: 'shares/reRevoke',
switchAdmin: 'shares/switchAdmin',
setPublicPollEmail: 'shares/setPublicPollEmail',
setLabel: 'shares/writeLabel',
deleteUser: 'votes/deleteUser',
}),
async clickDeleted(share) {
try {
if (share.voted && share.revoked) {
this.deleteShare({ share })
this.deleteUser({ userId: share.userId })
showSuccess(t('polls', 'Deleted share and votes for {displayName}', { displayName: share.displayName }))
} else if (share.voted && !share.revoked) {
this.revokeShare({ share })
showSuccess(t('polls', 'Share for user {displayName} revoked', { displayName: share.displayName }))
} else {
this.deleteShare({ share })
showSuccess(t('polls', 'Deleted share for user {displayName}', { displayName: share.displayName }))
}
} catch (e) {
showError(t('polls', 'Error deleting or revoking share for user {displayName}', { displayName: share.displayName }))
console.error('Error deleting or revoking share', { share }, e.response)
}
},
async writeLabel() {
this.setLabel({ token: this.share.token, displayName: this.share.displayName })
},

Просмотреть файл

@ -30,9 +30,9 @@
<ShareItemAllUsers v-if="allowAllAccess" />
<SharePublicAdd v-if="allowPublicShares" />
<div v-if="invitationShares.length" class="shares-list shared">
<div v-if="activeShares.length" class="shares-list shared">
<TransitionGroup :css="false" tag="div">
<ShareItem v-for="(share) in invitationShares"
<ShareItem v-for="(share) in activeShares"
:key="share.id"
:share="share"
@show-qr-code="openQrModal(share)" />
@ -94,7 +94,7 @@ export default {
pollDescription: (state) => state.poll.description,
}),
...mapGetters({
invitationShares: 'shares/invitation',
activeShares: 'shares/active',
}),
},

Просмотреть файл

@ -0,0 +1,68 @@
<!--
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
-
- @author René Gieling <github@dartcafe.de>
-
- @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>
<ConfigBox v-if="revokedShares.length" :title="t('polls', 'Revoked shares')">
<template #icon>
<DeletedIcon />
</template>
<TransitionGroup :css="false" tag="div" class="shares-list">
<ShareItem v-for="(share) in revokedShares"
:key="share.id"
:share="share" />
</TransitionGroup>
</ConfigBox>
</template>
<script>
import { mapGetters, mapActions, mapState } from 'vuex'
import { ConfigBox } from '../Base/index.js'
import DeletedIcon from 'vue-material-design-icons/Delete.vue'
import ShareItem from './ShareItem.vue'
export default {
name: 'SharesListRevoked',
components: {
DeletedIcon,
ConfigBox,
ShareItem,
},
computed: {
...mapState({
pollId: (state) => state.poll.id,
}),
...mapGetters({
revokedShares: 'shares/revoked',
}),
},
methods: {
...mapActions({
removeShare: 'shares/delete',
inviteAll: 'shares/inviteAll',
}),
},
}
</script>

Просмотреть файл

@ -24,6 +24,7 @@
<div class="sidebar-share">
<SharesListUnsent class="shares unsent" />
<SharesList class="shares effective" />
<SharesListRevoked class="shares" />
</div>
</template>
@ -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: {

Просмотреть файл

@ -31,7 +31,8 @@
:show-user-status="showUserStatus"
:user="avatarUserId"
:display-name="name"
:is-no-user="isNoUser">
:is-no-user="isNoUser"
@click="showMenu()">
<template v-if="useIconSlot" #icon>
<LinkIcon v-if="type==='public'" :size="mdIconSize" />
<InternalLinkIcon v-if="type==='internalAccess'" :size="mdIconSize" />
@ -124,6 +125,10 @@ export default {
type: String,
default: '',
},
forcedDescription: {
type: String,
default: null,
},
type: {
type: String,
default: 'user',
@ -178,6 +183,7 @@ export default {
return !['user', 'admin'].includes(this.type)
},
description() {
if (this.forcedDescription) return this.forcedDescription
if (this.type === 'admin') return t('polls', 'Is granted admin rights for this poll')
if (this.displayEmailAddress) return this.displayEmailAddress
return ''

Просмотреть файл

@ -27,6 +27,7 @@ const defaultShares = () => ({
displayName: '',
id: null,
invitationSent: 0,
revoked: 0,
pollId: null,
token: '',
type: '',

Просмотреть файл

@ -60,17 +60,25 @@ const mutations = {
}
const getters = {
invitation: (state) => {
active: (state) => {
// share types, which will be active, after the user gets his invitation
const invitationTypes = ['email', 'external', 'contact']
// sharetype which are active without sending an invitation
const directShareTypes = ['user', 'group', 'admin', 'public']
return state.list.filter((share) => (invitationTypes.includes(share.type) && (share.type === 'external' || share.invitationSent)) || directShareTypes.includes(share.type))
return state.list.filter((share) => (!share.revoked
&& (directShareTypes.includes(share.type)
|| (invitationTypes.includes(share.type) && (share.type === 'external' || share.invitationSent || share.voted))
)
))
},
unsentInvitations: (state) => state.list.filter((share) => (share.emailAddress || share.type === 'group' || share.type === 'contactGroup' || share.type === 'circle') && !share.invitationSent),
revoked: (state) => state.list.filter((share) => (!!share.revoked)),
unsentInvitations: (state) => state.list.filter((share) =>
(share.emailAddress || share.type === 'group' || share.type === 'contactGroup' || share.type === 'circle')
&& !share.invitationSent && !share.revoked && !share.voted),
public: (state) => state.list.filter((share) => ['public'].includes(share.type)),
hasShares: (state) => state.list.length > 0,
hasRevoked: (state, getters) => getters.revoked.length > 0,
}
const actions = {
@ -173,6 +181,32 @@ const actions = {
}
},
async revoke(context, payload) {
// context.commit('delete', { share: payload.share })
try {
await SharesAPI.revokeShare(payload.share.token)
context.dispatch('list')
} catch (e) {
if (e?.code === 'ERR_CANCELED') return
console.error('Error revoking share', { error: e.response }, { payload })
context.dispatch('list')
throw e
}
},
async reRevoke(context, payload) {
// context.commit('delete', { share: payload.share })
try {
await SharesAPI.reRevokeShare(payload.share.token)
context.dispatch('list')
} catch (e) {
if (e?.code === 'ERR_CANCELED') return
console.error('Error re-revoking share', { error: e.response }, { payload })
context.dispatch('list')
throw e
}
},
async delete(context, payload) {
context.commit('delete', { share: payload.share })
try {

Просмотреть файл

@ -60,7 +60,6 @@ const getters = {
relevant: (state, getters, rootState) => state.list.filter((vote) => rootState.options.list.some((option) => option.pollId === vote.pollId && option.text === vote.optionText)),
countVotes: (state, getters, rootState) => (answer) => getters.relevant.filter((vote) => vote.user.userId === rootState.poll.acl.userId && vote.answer === answer).length,
countAllVotes: (state, getters) => (answer) => getters.relevant.filter((vote) => vote.answer === answer).length,
hasVoted: (state) => (userId) => state.list.findIndex((vote) => vote.user.userId === userId) > -1,
hasVotes: (state) => state.list.length > 0,
getVote: (state) => (payload) => {

Просмотреть файл

@ -21,7 +21,8 @@
-->
<template>
<NcAppSidebar :active.sync="activeTab"
<NcAppSidebar v-show="showSidebar"
:active.sync="activeTab"
:title="t('polls', 'Details')"
@close="closeSideBar()">
<NcAppSidebarTab v-if="acl.allowEdit"
@ -112,6 +113,7 @@ export default {
data() {
return {
activeTab: t('polls', 'Comments').toLowerCase(),
showSidebar: (window.innerWidth > 920),
}
},
@ -129,10 +131,16 @@ export default {
subscribe('polls:sidebar:changeTab', (payload) => {
this.activeTab = payload?.activeTab ?? this.activeTab
})
subscribe('polls:sidebar:toggle', (payload) => {
emit('polls:sidebar:changeTab', { activeTab: payload?.activeTab })
this.showSidebar = payload?.open ?? !this.showSidebar
})
},
beforeDestroy() {
unsubscribe('polls:sidebar:changeTab')
unsubscribe('polls:sidebar:toggle')
},
methods: {