diff --git a/README.md b/README.md index 16701a6078..7116409ed5 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ ![lint](https://github.com/nextcloud/polls/actions/workflows/lint.yml/badge.svg) [![Dependabot status](https://img.shields.io/badge/Dependabot-enabled-brightgreen.svg?longCache=true&style=flat-square&logo=dependabot)](https://dependabot.com) -[![Software License](https://img.shields.io/badge/license-AGPL-brightgreen.svg?style=flat-square)](COPYING) [![REUSE status](https://api.reuse.software/badge/github.com/nextcloud/polls)](https://api.reuse.software/info/github.com/nextcloud/polls) +[![Software License](https://img.shields.io/badge/license-AGPL-brightgreen.svg?style=flat-square)](COPYING) +[![Nextcloud21+](https://img.shields.io/badge/Nextcloud%20Version-21%2B-0082C9?logo=nextcloud)](https://nextcloud.com) # Free meeting schedule tool - :next_track_button: Easy poll creation - :hammer_and_wrench: Highly customizable - :envelope: Make your poll confidential by hiding the results until you want them to be discovered - - :dark_sunglasses: Obfuscate participants' names from other participants + - :dark_sunglasses: Obfuscate participants' names from other participants or set strong anonymous mode - :timer_clock: Set an automatic expiry date - :heavy_plus_sign: Allow participants to add more options - :white_check_mark: Limit votes per option or user @@ -34,23 +35,14 @@ - Contacts - Activity -## Installation / Update -This app is supposed to work on Nextcloud version 21+. +Find Polls in the [Nextcloud app store](https://apps.nextcloud.com/apps/polls). -### Install latest release -You can download and install the latest release from the [Nextcloud app store](https://apps.nextcloud.com/apps/polls). +## Installation / Update +See [wiki](https://github.com/nextcloud/polls/wiki/Installation-help). ## Available occ commands -| Command | Description | -| - | - | -| `polls:db:clean-migrations` | Remove obsolete migrations, which are no more needed | -| `polls:db:purge` | Drop Polls' tables and remove migration and settings records | -| `polls:db:rebuild` | Rebuild Polls' database including indices | -| `polls:index:create` | Create all necessary indices and foreign key constraints | -| `polls:index:remove` | Remove all indices | -| `polls:poll:transfer-ownership ` | Transfer poll ownership from to | -| `polls:share:add [--user USER] [--group GROUP] [--email EMAIL] [--] ` | Add user/group/email with to shares | -| `polls:share:remove [--user USER] [--group GROUP] [--email EMAIL] [--] ` | Remove user/group/email with from shares | +See [wiki](https://github.com/nextcloud/polls/wiki/OCC-commands). + ## Support - Report a bug or request a feature: https://github.com/nextcloud/polls/issues - Community support: https://help.nextcloud.com/c/apps/polls/ @@ -60,49 +52,8 @@ Manage your polls and create new ones | Many configuration options | Share your :-:|:-:|:-:|:-: ![Manage Polls](screenshots/overview.png) | ![Vote](screenshots/edit-poll.png) | ![Edit poll](screenshots/share.png) | ![Share poll](screenshots/vote.png) -## For the 7.x release branch (v7.x) please switch to the [master-7 branch](https://github.com/nextcloud/polls/tree/master-7) -### Install from git -If you want to run the latest development version from git source, you need to clone the repo to your apps folder: - -``` -git clone https://github.com/nextcloud/polls.git -``` - -* Install dev environment with ```make setup-dev``` or -* install runtime environment with ```make setup-build``` -* Compile javascript with ```npm run build``` -* Run a complete build with ```make appstore``` (Find the output in the build directory) -* call `occ app:enable polls` to enable Polls - -### Installation variants - -### First time install -Nextcloud executes -* unexecuted `migration classes` (not listed in the `*_migrations` table) and the -* `install` repair step. - -### After a version update (changed version attribute in appinfo/info.xml) -Nextcloud executes -* `pre-migration` repair steps, -* unexecuted `migration classes` (not listed in the `*_migrations` table) and the -* `post-migration` repair steps - -### Enabling already installed but disabled app without version change -Nextcloud executes -* `pre-migration` repair steps, -* unexecuted `migration classes` (not listed in the `*_migrations` table) and the -* `post-migration` repair steps and the -* `install` repair step - -❗ As a compromise at the moment we allow the index creation to be ran twice when enabling the app via app store or `occ`, to ensure all indexes are created properly for every install/update/enabling path. - -## Removing Polls from instance -Call `occ polls:db:purge` to remove Polls completely. -* removes all Polls related tables -* removes all Polls related migration records -* removes all Polls related app config records (this also disables Polls) - -This does not remove Polls' files (call `occ app:remove polls` to remove it complete afterwards) but it resets Polls into an 'uninstalled' state. Enabling the app is then equivalent to a first time install and calls the migration and the install repair step (see above). +### Installation help +See [wiki](https://github.com/nextcloud/polls/wiki/Installation-help). ## Contribution Guidelines Please read the [Code of Conduct](https://nextcloud.com/community/code-of-conduct/). This document offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other. diff --git a/appinfo/info.xml b/appinfo/info.xml index b707ebe5ab..4a3f6e726e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -19,9 +19,9 @@ https://help.nextcloud.com/tag/polls-app https://github.com/nextcloud/polls/issues https://github.com/nextcloud/polls.git - https://raw.githubusercontent.com/nextcloud/polls/master/screenshots/overview.png - https://raw.githubusercontent.com/nextcloud/polls/master/screenshots/vote.png - https://raw.githubusercontent.com/nextcloud/polls/master/screenshots/edit-poll.png + https://raw.githubusercontent.com/nextcloud/polls/main/screenshots/overview.png + https://raw.githubusercontent.com/nextcloud/polls/main/screenshots/vote.png + https://raw.githubusercontent.com/nextcloud/polls/main/screenshots/edit-poll.png @@ -47,15 +47,17 @@ - OCA\Polls\Command\Share\Add - OCA\Polls\Command\Share\Remove + OCA\Polls\Command\Db\CleanMigrations + OCA\Polls\Command\Db\CreateIndices OCA\Polls\Command\Db\Purge - OCA\Polls\Command\Db\RemoveIndices OCA\Polls\Command\Db\Rebuild - OCA\Polls\Command\Db\CreateIndices - OCA\Polls\Command\Db\CleanMigrations + OCA\Polls\Command\Db\RemoveFKConstraints + OCA\Polls\Command\Db\RemoveOptionalIndices + OCA\Polls\Command\Db\RemoveUniqueIndices OCA\Polls\Command\Db\ResetWatch OCA\Polls\Command\Poll\TransferOwnership + OCA\Polls\Command\Share\Add + OCA\Polls\Command\Share\Remove OCA\Polls\Settings\AdminSettings diff --git a/img/app.svg b/img/app.svg index f94d056b24..e33d6ebb5c 100644 --- a/img/app.svg +++ b/img/app.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/img/polls-dark.svg b/img/polls-dark.svg index 7dfcf77b34..544b9313c2 100644 --- a/img/polls-dark.svg +++ b/img/polls-dark.svg @@ -1,5 +1,7 @@ - - - + + + + + diff --git a/img/polls.svg b/img/polls.svg index 410ea6fc34..5db2d53358 100644 --- a/img/polls.svg +++ b/img/polls.svg @@ -1,5 +1,7 @@ - - - + + + + + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d92633f54e..48ea5b9d75 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -44,6 +44,7 @@ use OCA\Polls\Event\ShareTypeChangedEvent; use OCA\Polls\Event\VoteEvent; use OCA\Polls\Event\VoteSetEvent; +use OCA\Polls\Listener\AddMissingIndicesListener; use OCA\Polls\Listener\CommentListener; use OCA\Polls\Listener\GroupDeletedListener; use OCA\Polls\Listener\OptionListener; @@ -64,6 +65,7 @@ use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\DB\Events\AddMissingIndicesEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\IAppConfig; use OCP\IDBConnection; @@ -91,6 +93,7 @@ public function register(IRegistrationContext $context): void { include_once __DIR__ . '/../../vendor/autoload.php'; $this->registerServices($context); + $context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class); $context->registerEventListener(RenderReferenceEvent::class, PollsReferenceListener::class); $context->registerMiddleWare(RequestAttributesMiddleware::class); $context->registerNotifierService(Notifier::class); diff --git a/lib/Command/Db/CleanMigrations.php b/lib/Command/Db/CleanMigrations.php index 95fedcc261..1ff2b04468 100644 --- a/lib/Command/Db/CleanMigrations.php +++ b/lib/Command/Db/CleanMigrations.php @@ -20,8 +20,7 @@ class CleanMigrations extends Command { protected string $name = parent::NAME_PREFIX . 'db:clean-migrations'; protected string $description = 'Remove old migrations entries from Nextcloud\'s migration table'; protected array $operationHints = [ - 'All polls tables will get checked against the current schema.', - 'NO data migration will be executed, so make sure you have a backup of your database.', + 'Note: NO data migration will be executed, so make sure you have a backup of your database.', ]; public function __construct( diff --git a/lib/Command/Db/CreateIndices.php b/lib/Command/Db/CreateIndices.php index 8d1aa7b429..1f8fefd6b7 100644 --- a/lib/Command/Db/CreateIndices.php +++ b/lib/Command/Db/CreateIndices.php @@ -18,10 +18,11 @@ */ class CreateIndices extends Command { protected string $name = parent::NAME_PREFIX . 'index:create'; - protected string $description = 'Add all indices and foreign key constraints'; + protected string $description = 'Add unique indices and foreign key constraints'; protected array $operationHints = [ - 'Adds indices and foreing key constraints.', - 'NO data migration will be executed, so make sure you have a backup of your database.', + 'Adds unique indices and foreign key constraints.', + 'To create the optional indices, run the command \'occ db:add-missing-indices\'', + 'Note: NO data migration will be executed, so make sure you have a backup of your database.', ]; public function __construct( @@ -38,7 +39,7 @@ protected function runCommands(): int { $this->schema = $this->connection->createSchema(); $this->indexManager->setSchema($this->schema); $this->addForeignKeyConstraints(); - $this->addIndices(); + $this->addUniqueIndices(); $this->connection->migrateToSchema($this->schema); return 0; @@ -56,9 +57,9 @@ private function addForeignKeyConstraints(): void { /** * Create index for $table */ - private function addIndices(): void { + private function addUniqueIndices(): void { $this->printComment('Add indices'); - $messages = $this->indexManager->createIndices(); + $messages = $this->indexManager->createUniqueIndices(); $this->printInfo($messages, ' - '); } } diff --git a/lib/Command/Db/Purge.php b/lib/Command/Db/Purge.php index 4dbcba7f4a..26bf499bae 100644 --- a/lib/Command/Db/Purge.php +++ b/lib/Command/Db/Purge.php @@ -25,6 +25,7 @@ class Purge extends Command { ' - delete Polls\'s app config records from oc_appconfig.', ' ', 'after running this command call \'occ app:remove polls \'', + 'Note: Make sure you have a backup of your database.', ]; public function __construct( @@ -38,6 +39,9 @@ protected function runCommands(): int { $this->tableManager->setConnection($this->connection); $messages = $this->tableManager->purgeTables(); $this->printInfo($messages, ' - '); + $this->printInfo($messages, 'Polls has been completely wiped off the database.'); + $this->printInfo($messages, ''); + $this->printInfo($messages, '!!! Now call \'occ app:remove polls \' to remove the app completely.'); return 0; } } diff --git a/lib/Command/Db/Rebuild.php b/lib/Command/Db/Rebuild.php index a55cd52a25..623815c405 100644 --- a/lib/Command/Db/Rebuild.php +++ b/lib/Command/Db/Rebuild.php @@ -21,14 +21,15 @@ class Rebuild extends Command { protected string $name = parent::NAME_PREFIX . 'db:rebuild'; protected string $description = 'Rebuilds poll\'s table structure'; protected array $operationHints = [ - 'All polls tables will get checked against the current schema.', - 'NO data migration will be executed, so make sure you have a backup of your database.', - '', + 'All polls tables will get checked and eventually updated against the current schema.', '*****************************', '** Please understand **', '*****************************', - 'The process will also recreate all indices and foreign key constraints.', + 'The process will also remove all optional indices.', 'This can lead to a database performance impact on the app after the recreation is done.', + '', + 'To recreate the optional indices, run the command \'occ db:add-missing-indices\'', + 'Note: NO data migration will be executed, so make sure you have a backup of your database.', ]; public function __construct( @@ -69,9 +70,11 @@ protected function runCommands(): int { $this->printComment('Step 5. Remove invalid records (orphaned and duplicates)'); $this->cleanTables(); - $this->printComment('Step 6. Recreate indices and foreign key constraints'); + $this->printComment('Step 6. Recreate unique indices and foreign key constraints'); $this->addForeignKeyConstraints(); - $this->addIndices(); + $this->addUniqueIndices(); + + $this->printComment('Execute \'occ db:add-missing-indices\' to add missing optional indices'); $this->connection->migrateToSchema($this->schema); @@ -90,9 +93,9 @@ private function addForeignKeyConstraints(): void { /** * Create index for $table */ - private function addIndices(): void { - $this->printComment(' - Add indices'); - $messages = $this->indexManager->createIndices(); + private function addUniqueIndices(): void { + $this->printComment(' - Add unique indices'); + $messages = $this->indexManager->createUniqueIndices(); $this->printInfo($messages, ' '); } diff --git a/lib/Command/Db/RemoveIndices.php b/lib/Command/Db/RemoveFKConstraints.php similarity index 60% rename from lib/Command/Db/RemoveIndices.php rename to lib/Command/Db/RemoveFKConstraints.php index 0b99f353d2..7d60ba5349 100644 --- a/lib/Command/Db/RemoveIndices.php +++ b/lib/Command/Db/RemoveFKConstraints.php @@ -16,12 +16,19 @@ /** * @psalm-api */ -class RemoveIndices extends Command { - protected string $name = parent::NAME_PREFIX . 'index:remove'; - protected string $description = 'Remove all indices and foreign key constraints'; +class RemoveFKConstraints extends Command { + protected string $name = parent::NAME_PREFIX . 'index:remove:foreign-key-constraints'; + protected string $description = 'Remove all foreign key constraints'; protected array $operationHints = [ - 'Removes all indices and foreign key constraints.', - 'NO data migration will be executed, so make sure you have a backup of your database.', + 'Remove all foreign key constraints.', + '*****************************', + '** Please understand **', + '*****************************', + 'This can lead to inconsitent database states, because it affects the database integrity.', + 'Therefoe this is highly NOT RECOMMENDED and should only be used if you know what you are doing.', + '', + 'To recreate the Foreign key constraints, run the command \'occ ' . parent::NAME_PREFIX . 'index:create\'', + 'Note: NO data migration will be executed, so make sure you have a backup of your database.', ]; public function __construct( @@ -38,23 +45,13 @@ protected function runCommands(): int { $this->indexManager->setSchema($this->schema); $this->deleteForeignKeyConstraints(); $this->deleteGenericIndices(); - $this->deleteUniqueIndices(); - $this->deleteNamedIndices(); $this->connection->migrateToSchema($this->schema); return 0; } /** - * remove on delete fk contraint from all tables referencing the main polls table - */ - private function deleteForeignKeyConstraints(): void { - $this->printComment('Remove foreign key constraints and generic indices'); - $messages = $this->indexManager->removeAllForeignKeyConstraints(); - $this->printInfo($messages, ' - '); - } - - /** - * remove all generic indices + * remove all generic indices (the only generic indices should + * result from the FK Constraints) */ private function deleteGenericIndices(): void { $this->printComment('Remove generic indices'); @@ -63,19 +60,11 @@ private function deleteGenericIndices(): void { } /** - * remove all unique indices - */ - private function deleteUniqueIndices(): void { - $this->printComment('Remove unique indices'); - $messages = $this->indexManager->removeAllUniqueIndices(); - $this->printInfo($messages, ' - '); - } - /** - * remove all named indices + * remove on delete fk contraint from all tables referencing the main polls table */ - private function deleteNamedIndices(): void { - $this->printComment('Remove common indices'); - $messages = $this->indexManager->removeNamedIndices(); + private function deleteForeignKeyConstraints(): void { + $this->printComment('Remove foreign key constraints and generic indices'); + $messages = $this->indexManager->removeAllForeignKeyConstraints(); $this->printInfo($messages, ' - '); } } diff --git a/lib/Command/Db/RemoveOptionalIndices.php b/lib/Command/Db/RemoveOptionalIndices.php new file mode 100644 index 0000000000..23fcf37a01 --- /dev/null +++ b/lib/Command/Db/RemoveOptionalIndices.php @@ -0,0 +1,53 @@ +schema = $this->connection->createSchema(); + $this->indexManager->setSchema($this->schema); + $this->deleteNamedIndices(); + $this->connection->migrateToSchema($this->schema); + return 0; + } + + /** + * remove all named indices + */ + private function deleteNamedIndices(): void { + $this->printComment('Remove optional indices'); + $messages = $this->indexManager->removeNamedIndices(); + $this->printInfo($messages, ' - '); + } +} diff --git a/lib/Command/Db/RemoveUniqueIndices.php b/lib/Command/Db/RemoveUniqueIndices.php new file mode 100644 index 0000000000..2d87b9e2ca --- /dev/null +++ b/lib/Command/Db/RemoveUniqueIndices.php @@ -0,0 +1,59 @@ +schema = $this->connection->createSchema(); + $this->indexManager->setSchema($this->schema); + $this->deleteUniqueIndices(); + $this->connection->migrateToSchema($this->schema); + return 0; + } + + /** + * remove all unique indices + */ + private function deleteUniqueIndices(): void { + $this->printComment('Remove unique indices'); + $messages = $this->indexManager->removeAllUniqueIndices(); + $this->printInfo($messages, ' - '); + } +} diff --git a/lib/Command/Db/ResetWatch.php b/lib/Command/Db/ResetWatch.php index 79dfb5fe4a..7380b36c75 100644 --- a/lib/Command/Db/ResetWatch.php +++ b/lib/Command/Db/ResetWatch.php @@ -23,8 +23,7 @@ class ResetWatch extends Command { protected string $name = parent::NAME_PREFIX . 'db:reset-watch'; protected string $description = 'Resets the Watch table'; protected array $operationHints = [ - 'All polls tables will get checked against the current schema.', - 'NO data migration will be executed, so make sure you have a backup of your database.', + 'Removes and recreates the watch table to set ids to zero', ]; public function __construct( @@ -38,8 +37,6 @@ public function __construct( protected function runCommands(): int { $tableName = Watch::TABLE; - $indexValues = TableSchema::UNIQUE_INDICES[$tableName]; - $columns = TableSchema::TABLES[$tableName]; $messages = $this->tableManager->removeWatch(); $this->printInfo($messages, ' - '); @@ -48,8 +45,11 @@ protected function runCommands(): int { $this->indexManager->setSchema($this->schema); $this->tableManager->setSchema($this->schema); - $messages = $this->tableManager->createTable($tableName, $columns); - $messages[] = $this->indexManager->createIndex($tableName, $indexValues['name'], $indexValues['columns'], $indexValues['unique']); + $messages = $this->tableManager->createTable($tableName); + + foreach (TableSchema::UNIQUE_INDICES[$tableName] as $name => $definition) { + $messages[] = $this->indexManager->createIndex($tableName, $name, $definition['columns'], true); + } $this->connection->migrateToSchema($this->schema); diff --git a/lib/Command/Db/removeOrphanedVotes.php b/lib/Command/Db/removeOrphanedVotes.php deleted file mode 100644 index be787c1693..0000000000 --- a/lib/Command/Db/removeOrphanedVotes.php +++ /dev/null @@ -1,64 +0,0 @@ -schema = $this->connection->createSchema(); - $this->indexManager->setSchema($this->schema); - $this->addForeignKeyConstraints(); - $this->addIndices(); - $this->connection->migrateToSchema($this->schema); - - return 0; - } - - /** - * add an on delete fk contraint to all tables referencing the main polls table - */ - private function addForeignKeyConstraints(): void { - $this->printComment('Add foreign key constraints'); - $messages = $this->indexManager->createForeignKeyConstraints(); - $this->printInfo($messages, ' - '); - } - - /** - * Create index for $table - */ - private function addIndices(): void { - $this->printComment('Add indices'); - $messages = $this->indexManager->createIndices(); - $this->printInfo($messages, ' - '); - } -} diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index c9e9b6cfba..8fcfebf02a 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -431,7 +431,7 @@ public function setDisplayName(string $token, string $displayName): JSONResponse #[FrontpageRoute(verb: 'PUT', url: '/s/{token}/email/{emailAddress}')] public function setEmailAddress(string $token, string $emailAddress = ''): JSONResponse { return $this->response(fn () => [ - 'share' => $this->shareService->setEmailAddress($this->shareService->get($token), $emailAddress) + 'share' => $this->shareService->setEmailAddress($this->shareService->request($token), $emailAddress) ]); } @@ -445,7 +445,7 @@ public function setEmailAddress(string $token, string $emailAddress = ''): JSONR #[FrontpageRoute(verb: 'DELETE', url: '/s/{token}/email')] public function deleteEmailAddress(string $token): JSONResponse { return $this->response(fn () => [ - 'share' => $this->shareService->deleteEmailAddress($this->shareService->get($token)) + 'share' => $this->shareService->deleteEmailAddress($this->shareService->request($token)) ]); } @@ -477,7 +477,7 @@ public function register(string $token, string $displayName, string $emailAddres #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/s/{token}/resend')] public function resendInvitation(string $token): JSONResponse { - $share = $this->shareService->get($token); + $share = $this->shareService->request($token); return $this->response(fn () => [ 'share' => $share, 'sentResult' => $this->mailService->sendInvitation($share, new SentResult()), diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index c5418f7f39..beac905187 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -8,6 +8,7 @@ namespace OCA\Polls\Controller; +use OCA\Polls\Attributes\ShareTokenRequired; use OCA\Polls\Model\SentResult; use OCA\Polls\Service\MailService; use OCA\Polls\Service\ShareService; @@ -16,6 +17,7 @@ use OCP\AppFramework\Http\Attribute\CORS; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; @@ -48,11 +50,59 @@ public function list(int $pollId): DataResponse { * Get share by token */ #[CORS] + #[PublicPage] #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/v1.0/share/{token}', requirements: ['apiVersion' => '(v2)'])] public function get(string $token): DataResponse { - return $this->response(fn () => ['share' => $this->shareService->get($token)]); + return $this->response(fn () => ['share' => $this->shareService->request($token)]); + } + + + #[CORS] + #[PublicPage] + #[ShareTokenRequired] + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: 'api/v1.0/s/{token}/register')] + public function register(string $token, string $displayName, string $emailAddress = '', string $timeZone = ''): DataResponse { + return $this->response(fn () => [ + 'share' => $this->shareService->register($token, $displayName, $emailAddress, $timeZone), + ], Http::STATUS_CREATED); + } + + /** + * Set EmailAddress + * @param string $token Share token + * @param string $emailAddress New email address + */ + #[CORS] + #[PublicPage] + #[ShareTokenRequired] + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'PUT', url: 'api/v1.0/s/{token}/email/{emailAddress}')] + public function setEmailAddress(string $token, string $emailAddress = ''): DataResponse { + return $this->response(fn () => [ + 'share' => $this->shareService->setEmailAddress($this->shareService->request($token), $emailAddress) + ]); + } + + /** + * Delete email address from share + * @param string $token Share token + * @return DataResponse + */ + #[CORS] + #[PublicPage] + #[ShareTokenRequired] + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'DELETE', url: 'api/v1.0/s/{token}/email')] + public function deleteEmailAddress(string $token): DataResponse { + return $this->response(fn () => [ + 'share' => $this->shareService->deleteEmailAddress($this->shareService->request($token)) + ]); } /** diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php index 9ed23afaf4..bffef77b06 100644 --- a/lib/Controller/ShareController.php +++ b/lib/Controller/ShareController.php @@ -251,22 +251,4 @@ public function resolveGroup(string $token): JSONResponse { 'shares' => $this->shareService->resolveGroupByToken($token) ]); } - - /** - * Set email address - * @param string $token Share token - * @param string $emailAddress Email address - * @deprecated 8.0.0 Use PUT /s/{token}/email/{emailAddress} - */ - #[NoAdminRequired] - #[OpenAPI(OpenAPI::SCOPE_IGNORE)] - #[FrontpageRoute(verb: 'PUT', url: '/share/{token}/email')] - public function setEmailAddress(string $token, string $emailAddress = ''): JSONResponse { - return $this->response(fn () => [ - 'share' => $this->shareService->setEmailAddress( - $this->shareService->get($token), - $emailAddress - ) - ]); - } } diff --git a/lib/Db/IndexManager.php b/lib/Db/IndexManager.php index f5e289a40a..ebd34a5d10 100644 --- a/lib/Db/IndexManager.php +++ b/lib/Db/IndexManager.php @@ -35,19 +35,37 @@ public function setSchema(Schema &$schema): void { } /** - * Create all indices + * Create unique indices + * Unique indices are crucial for the correct operation of the polls app. + * This for they have to be updated on every update. * * @return string[] logged messages */ - public function createIndices(): array { + public function createUniqueIndices(): array { $messages = []; - foreach (TableSchema::UNIQUE_INDICES as $tableName => $values) { - $messages[] = $this->createIndex($tableName, $values['name'], $values['columns'], $values['unique']); + foreach (TableSchema::UNIQUE_INDICES as $tableName => $uniqueIndices) { + foreach ($uniqueIndices as $name => $definition) { + $messages[] = $this->createIndex($tableName, $name, $definition['columns'], true); + } } + return $messages; + } - foreach (TableSchema::COMMON_INDICES as $index) { - $messages[] = $this->createIndex($index['table'], $index['name'], $index['columns'], $index['unique']); + /** + * Create optional indices + * Usually they should be created by the AddMissingIndicesListener + * or on first time installation of polls. + * + * @return string[] logged messages + */ + public function createOptionalIndices(): array { + $messages = []; + + foreach (TableSchema::OPTIONAL_INDICES as $table => $indices) { + foreach ($indices as $name => $definition) { + $messages[] = $this->createIndex($table, $name, $definition['columns']); + } } return $messages; @@ -55,6 +73,8 @@ public function createIndices(): array { /** * add on delete fk contraints to all tables referencing the main polls table + * Foreign key constraints are crucial for the correct operation of the polls app. + * This for they have to be updated on every update. * * @return string[] logged messages */ @@ -71,7 +91,7 @@ public function createForeignKeyConstraints(): array { } /** - * add an on delete fk contraint + * add one on delete fk contraint * * @param string $parentTableName name of referred table * @param string $childTableName name of referring table @@ -88,7 +108,7 @@ public function createForeignKeyConstraint(string $parentTableName, string $chil } /** - * Create named index for table + * Create one named index for table * * @param string $tableName name of table to add the index to * @param string $indexName index name @@ -142,11 +162,8 @@ public function removeAllForeignKeyConstraints(): array { public function removeAllGenericIndices(): array { $messages = []; - foreach (TableSchema::FK_INDICES as $child) { - foreach (array_keys($child) as $table) { - $messages = array_merge($messages, $this->removeForeignKeysFromTable($table)); - $messages = array_merge($messages, $this->removeGenericIndicesFromTable($table)); - } + foreach (array_keys(TableSchema::TABLES) as $table) { + $messages = array_merge($messages, $this->removeGenericIndicesFromTable($table)); } return $messages; @@ -159,10 +176,12 @@ public function removeAllGenericIndices(): array { public function removeNamedIndices(): array { $messages = []; - foreach (TableSchema::COMMON_INDICES as $index) { - $message = $this->removeNamedIndexFromTable($index['table'], $index['name']); - if ($message !== null && $message !== '') { - $messages[] = $message; + foreach (TableSchema::OPTIONAL_INDICES as $table => $indices) { + foreach (array_keys($indices) as $name) { + $message = $this->removeNamedIndexFromTable($table, $name); + if ($message !== null && $message !== '') { + $messages[] = $message; + } } } diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 8ac8af5d88..4e379c2dce 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -14,6 +14,7 @@ use DateTimeZone; use JsonSerializable; use OCA\Polls\Exceptions\InsufficientAttributesException; +use OCA\Polls\Helper\Hash; use OCA\Polls\Model\SimpleOption; use OCA\Polls\Model\UserBase; use OCP\IL10N; @@ -36,6 +37,8 @@ * @method void setPollOptionText(string $value) * @method string getPollOptionHash() * @method void setPollOptionHash(string $value) + * @method string getPollOptionHashBin() + * @method void setPollOptionHashBin(string $value) * @method int getReleased() * @method void setReleased(int $value) * @method int getTimestamp() @@ -220,7 +223,10 @@ public function syncOption(): void { } private function updateHash(): void { - $this->setPollOptionHash(hash('md5', $this->getPollId() . $this->getPollOptionText() . $this->getTimestamp())); + $this->setPollOptionHash(Hash::getOptionHash( + $this->getPollId(), + $this->getPollOptionText() + )); } public function getPollOptionText(): string { diff --git a/lib/Db/OptionMapper.php b/lib/Db/OptionMapper.php index d28b3b0679..9524f4bf49 100644 --- a/lib/Db/OptionMapper.php +++ b/lib/Db/OptionMapper.php @@ -66,13 +66,13 @@ public function findByPoll(int $pollId, bool $hideResults = false, bool $getDele /** * @return Option * @param int $pollId - * @param string $pollOptionText option text + * @param string $pollOptionHash option text as hashed value (pollId and PollOptionText) * @param bool $getDeleted also search for deleted options */ - public function findByPollAndText(int $pollId, string $pollOptionText, bool $getDeleted = false): Option { + public function findByPollAndText(int $pollId, string $pollOptionHash, bool $getDeleted = false): Option { $qb = $this->buildQuery(); $qb->where($qb->expr()->eq(self::TABLE . '.poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq(self::TABLE . '.poll_option_text', $qb->createNamedParameter($pollOptionText, IQueryBuilder::PARAM_STR))); + ->andWhere($qb->expr()->eq(self::TABLE . '.poll_option_hash', $qb->createNamedParameter($pollOptionHash, IQueryBuilder::PARAM_STR))); if (!$getDeleted) { $qb->andWhere($qb->expr()->eq(self::TABLE . '.deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT))); } @@ -235,7 +235,7 @@ protected function joinVotesCount( $joinAlias, $qb->expr()->andX( $qb->expr()->eq($joinAlias . '.poll_id', $fromAlias . '.poll_id'), - $qb->expr()->eq($joinAlias . '.vote_option_text', $fromAlias . '.poll_option_text'), + $qb->expr()->eq($joinAlias . '.vote_option_hash', $fromAlias . '.poll_option_hash'), ) ) // Count number of votes for this option @@ -290,7 +290,7 @@ protected function joinCurrentUserVote( $qb->expr()->andX( $qb->expr()->eq($joinAlias . '.poll_id', $fromAlias . '.poll_id'), $qb->expr()->eq($joinAlias . '.user_id', $qb->createNamedParameter($currentUserId, IQueryBuilder::PARAM_STR)), - $qb->expr()->eq($joinAlias . '.vote_option_text', $fromAlias . '.poll_option_text'), + $qb->expr()->eq($joinAlias . '.vote_option_hash', $fromAlias . '.poll_option_hash'), ) ); } diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index ebd2497203..e2aee15f01 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -65,7 +65,7 @@ * @method void setVotingVariant(string $value) * * Magic functions for joined columns - * @method int getShareToken() + * @method string getShareToken() * @method int getCurrentUserVotes() * @method int getCurrentUserVotesYes() * @method int getCurrentUserVotesNo() @@ -81,7 +81,9 @@ class Poll extends EntityWithUser implements JsonSerializable { public const TYPE_DATE = 'datePoll'; public const TYPE_TEXT = 'textPoll'; public const VARIANT_SIMPLE = 'simple'; + /** @deprecated use ACCESS_PRIVATE instead */ public const ACCESS_HIDDEN = 'hidden'; + /** @deprecated use ACCESS_OPEN instead */ public const ACCESS_PUBLIC = 'public'; public const ACCESS_PRIVATE = 'private'; public const ACCESS_OPEN = 'open'; @@ -107,15 +109,15 @@ class Poll extends EntityWithUser implements JsonSerializable { public const ROLE_NONE = 'none'; public const PERMISSION_OVERRIDE = 'override_permission'; - public const PERMISSION_POLL_VIEW = 'view'; - public const PERMISSION_POLL_EDIT = 'edit'; - public const PERMISSION_POLL_CHANGE_OWNER = 'changeOwner'; - public const PERMISSION_POLL_DELETE = 'delete'; - public const PERMISSION_POLL_ARCHIVE = 'archive'; - public const PERMISSION_POLL_RESULTS_VIEW = 'seeResults'; + public const PERMISSION_POLL_ACCESS = 'accessPoll'; + public const PERMISSION_POLL_EDIT = 'editPoll'; + public const PERMISSION_POLL_CHANGE_OWNER = 'changePollOwner'; + public const PERMISSION_POLL_DELETE = 'deletePoll'; + public const PERMISSION_POLL_ARCHIVE = 'archivePoll'; + public const PERMISSION_POLL_RESULTS_VIEW = 'seePollResults'; public const PERMISSION_POLL_USERNAMES_VIEW = 'seeUserNames'; - public const PERMISSION_POLL_TAKEOVER = 'takeOver'; - public const PERMISSION_POLL_SUBSCRIBE = 'subscribe'; + public const PERMISSION_POLL_TAKEOVER = 'takeOverPoll'; + public const PERMISSION_POLL_SUBSCRIBE = 'subscribePoll'; public const PERMISSION_COMMENT_ADD = 'addComment'; public const PERMISSION_COMMENT_DELETE = 'deleteComment'; public const PERMISSION_OPTION_ADD = 'addOptions'; @@ -301,7 +303,7 @@ public function getPermissionsArray(): array { 'seeUsernames' => $this->getIsAllowed(self::PERMISSION_POLL_USERNAMES_VIEW), 'subscribe' => $this->getIsAllowed(self::PERMISSION_POLL_SUBSCRIBE), 'takeOver' => $this->getIsAllowed(self::PERMISSION_POLL_TAKEOVER), - 'view' => $this->getIsAllowed(self::PERMISSION_POLL_VIEW), + 'view' => $this->getIsAllowed(self::PERMISSION_POLL_ACCESS), 'vote' => $this->getIsAllowed(self::PERMISSION_VOTE_EDIT), ]; } @@ -340,6 +342,10 @@ public function getExpired(): bool { ); } + public function getPollOwnerId() { + return $this->getOwner(); + } + public function getUserRole(): string { if ($this->getCurrentUserIsEntityUser()) { return self::ROLE_OWNER; @@ -583,7 +589,7 @@ public function getIsAllowed(string $permission): bool { self::PERMISSION_OPTION_DELETE => $this->getAllowDeleteOption(), self::PERMISSION_OPTIONS_REORDER => $this->getAllowReorderOptions(), self::PERMISSION_OVERRIDE => true, - self::PERMISSION_POLL_VIEW => $this->getAllowAccessPoll(), + self::PERMISSION_POLL_ACCESS => $this->getAllowAccessPoll(), self::PERMISSION_POLL_EDIT => $this->getAllowEditPoll(), self::PERMISSION_POLL_DELETE => $this->getAllowDeletePoll(), self::PERMISSION_POLL_ARCHIVE => $this->getAllowEditPoll(), @@ -605,7 +611,7 @@ public function getIsAllowed(string $permission): bool { * getIsInvolved - Is current user involved in current poll? * @return bool Returns true, if the current user is involved in the poll via share, as a participant or as the poll owner. */ - private function getIsInvolved(): bool { + public function getIsInvolved(): bool { return ( $this->getIsPollOwner() || $this->getIsParticipant() diff --git a/lib/Db/PollMapper.php b/lib/Db/PollMapper.php index 32272389d9..78b74943da 100644 --- a/lib/Db/PollMapper.php +++ b/lib/Db/PollMapper.php @@ -36,42 +36,14 @@ public function __construct( * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result * @return Poll */ - public function get(int $id, bool $getDeleted = false, bool $withRoles = false): Poll { - $qb = $this->db->getQueryBuilder(); - $qb->select(self::TABLE . '.*') - ->from($this->getTableName(), self::TABLE) - ->where($qb->expr()->eq(self::TABLE . '.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) - ->groupBy(self::TABLE . '.id'); + public function get(int $id, bool $getDeleted = false): Poll { + $qb = $this->buildQuery(false); + $qb->where($qb->expr()->eq(self::TABLE . '.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); if (!$getDeleted) { $qb->andWhere($qb->expr()->eq(self::TABLE . '.deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT))); } - if ($withRoles) { - $pollGroupsAlias = 'poll_groups'; - $currentUserId = $this->userSession->getCurrentUserId(); - $currentUserParam = $qb->createNamedParameter($currentUserId, IQueryBuilder::PARAM_STR); - - $this->subQueryMaxDate($qb, self::TABLE); - - $this->joinUserRole($qb, self::TABLE, $currentUserParam); - $this->joinGroupShares($qb, self::TABLE); - $this->joinPollGroups($qb, self::TABLE, $pollGroupsAlias); - $this->joinPollGroupShares($qb, $pollGroupsAlias, $currentUserParam, $pollGroupsAlias); - } - return $this->findEntity($qb); - } - - /** - * Get poll with joins for operations with permissions and user informations - * @throws \OCP\AppFramework\Db\DoesNotExistException if not found - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result - * @return Poll - */ - public function find(int $id): Poll { - $qb = $this->buildQuery(); - $qb->where($qb->expr()->eq(self::TABLE . '.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); - return $this->findEntity($qb); } @@ -199,7 +171,7 @@ public function deleteByUserId(string $userId): void { /** * Build the enhanced query with joined tables */ - protected function buildQuery($detailed = true): IQueryBuilder { + protected function buildQuery(bool $detailed = true): IQueryBuilder { $qb = $this->db->getQueryBuilder(); $qb->select(self::TABLE . '.*') @@ -462,7 +434,7 @@ protected function subQueryOrphanedVotesCount( $optionAlias, $expr->andX( $expr->eq($optionAlias . '.poll_id', $subAlias . '.poll_id'), - $expr->eq($optionAlias . '.poll_option_text', $subAlias . '.vote_option_text'), + $expr->eq($optionAlias . '.poll_option_hash', $subAlias . '.vote_option_hash'), $expr->eq($optionAlias . '.deleted', $expr->literal(0, IQueryBuilder::PARAM_INT)) ) ) diff --git a/lib/Db/TableManager.php b/lib/Db/TableManager.php index 6bf06a902e..297ea8f461 100644 --- a/lib/Db/TableManager.php +++ b/lib/Db/TableManager.php @@ -13,6 +13,7 @@ use Doctrine\DBAL\Types\Type; use Exception; use OCA\Polls\AppConstants; +use OCA\Polls\Helper\Hash; use OCA\Polls\Migration\TableSchema; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IConfig; @@ -33,7 +34,6 @@ public function __construct( private OptionMapper $optionMapper, private VoteMapper $voteMapper, private Schema $schema, - private WatchMapper $watchMapper, ) { $this->setUp(); } @@ -60,8 +60,6 @@ public function setConnection(IDBConnection &$connection): void { */ public function purgeTables(): array { $messages = []; - - // drop all child tables $droppedTables = []; // First drop all tables that have foreign key constraints @@ -91,7 +89,7 @@ public function purgeTables(): array { } } - if (!$droppedTables) { + if ($droppedTables) { $this->logger->info('Dropped tables', $droppedTables); } @@ -143,17 +141,17 @@ public function removeWatch(): array { * * @psalm-return non-empty-list */ - public function createTable(string $tableName, array $columns): array { + public function createTable(string $tableName): array { $messages = []; + $columns = TableSchema::TABLES[$tableName]; + $ocTable = $this->dbPrefix . $tableName; - $tableName = $this->dbPrefix . $tableName; - - if ($this->schema->hasTable($tableName)) { - $table = $this->schema->getTable($tableName); + if ($this->schema->hasTable($ocTable)) { + $table = $this->schema->getTable($ocTable); $messages[] = 'Validating table ' . $table->getName(); $tableCreated = false; } else { - $table = $this->schema->createTable($tableName); + $table = $this->schema->createTable($ocTable); $tableCreated = true; $messages[] = 'Creating table ' . $table->getName(); } @@ -189,8 +187,8 @@ public function createTable(string $tableName, array $columns): array { public function createTables(): array { $messages = []; - foreach (TableSchema::TABLES as $tableName => $columns) { - $messages = array_merge($messages, $this->createTable($tableName, $columns)); + foreach (array_keys(TableSchema::TABLES) as $tableName) { + $messages = array_merge($messages, $this->createTable($tableName)); } return $messages; } @@ -332,16 +330,19 @@ public function removeOrphaned(): array { */ public function deleteAllDuplicates(?IOutput $output = null): array { $messages = []; - foreach (TableSchema::UNIQUE_INDICES as $tableName => $index) { - $count = $this->deleteDuplicates($tableName, $index['columns']); + foreach (TableSchema::UNIQUE_INDICES as $tableName => $uniqueIndices) { + foreach ($uniqueIndices as $definition) { - if ($count) { - $messages[] = 'Removed ' . $count . ' duplicate records from ' . $this->dbPrefix . $tableName; - $this->logger->info(end($messages)); - } + $count = $this->deleteDuplicates($tableName, $definition['columns']); - if ($output && $count) { - $output->info(end($messages)); + if ($count) { + $messages[] = 'Removed ' . $count . ' duplicate records from ' . $this->dbPrefix . $tableName; + $this->logger->info(end($messages)); + } + + if ($output && $count) { + $output->info(end($messages)); + } } } return $messages; @@ -349,8 +350,6 @@ public function deleteAllDuplicates(?IOutput $output = null): array { } private function deleteDuplicates(string $table, array $columns):int { - $this->watchMapper->deleteOldEntries(time()); - $qb = $this->connection->getQueryBuilder(); if ($this->schema->hasTable($this->dbPrefix . $table)) { @@ -448,8 +447,6 @@ public function migrateOptionsToHash(): array { foreach ($this->optionMapper->getAll(includeNull: true) as $option) { try { $option->syncOption(); - // $option->setPollOptionHash(hash('md5', $option->getPollId() . $option->getPollOptionText() . $option->getTimestamp())); - $this->optionMapper->update($option); $count++; } catch (Exception $e) { @@ -474,7 +471,7 @@ public function migrateOptionsToHash(): array { if ($table->hasColumn('vote_option_hash')) { foreach ($this->voteMapper->getAll(includeNull: true) as $vote) { try { - $vote->setVoteOptionHash(hash('md5', $vote->getPollId() . $vote->getUserId() . $vote->getVoteOptionText())); + $vote->setVoteOptionHash(Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText())); $this->voteMapper->update($vote); $count++; } catch (Exception $e) { diff --git a/lib/Db/Vote.php b/lib/Db/Vote.php index 5ce56387c1..924dc0d190 100644 --- a/lib/Db/Vote.php +++ b/lib/Db/Vote.php @@ -24,6 +24,8 @@ * @method void setVoteOptionText(string $value) * @method string getVoteOptionHash() * @method void setVoteOptionHash(string $value) + * @method string getVoteOptionHashBin() + * @method void setVoteOptionHashBin(string $value) * @method string getVoteAnswer() * @method void setVoteAnswer(string $value) * @method int getDeleted() diff --git a/lib/Db/VoteMapper.php b/lib/Db/VoteMapper.php index 5061a3b2b8..5de1ec3f7f 100644 --- a/lib/Db/VoteMapper.php +++ b/lib/Db/VoteMapper.php @@ -8,6 +8,7 @@ namespace OCA\Polls\Db; +use OCA\Polls\Helper\Hash; use OCA\Polls\UserSession; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\Entity; @@ -32,13 +33,13 @@ public function __construct( } public function update(Entity $entity): Vote { - $entity->setVoteOptionHash(hash('md5', $entity->getPollId() . $entity->getUserId() . $entity->getVoteOptionText())); + $entity->setVoteOptionHash(Hash::getOptionHash($entity->getPollId(), $entity->getVoteOptionText())); $entity = parent::update($entity); return $this->find($entity->getId()); } public function insert(Entity $entity): Vote { - $entity->setVoteOptionHash(hash('md5', $entity->getPollId() . $entity->getUserId() . $entity->getVoteOptionText())); + $entity->setVoteOptionHash(Hash::getOptionHash($entity->getPollId(), $entity->getVoteOptionText())); $entity = parent::insert($entity); return $this->find($entity->getId()); } @@ -170,7 +171,7 @@ public function removeOrphanedVotes(): int { 'votes', Option::TABLE, 'options', - 'votes.poll_id = options.poll_id AND votes.vote_option_text = options.poll_option_text' + 'votes.poll_id = options.poll_id AND votes.vote_option_hash = options.poll_option_hash' ); $qb->where('options.poll_id IS NULL'); @@ -256,7 +257,7 @@ protected function joinOption( $joinAlias, $qb->expr()->andX( $qb->expr()->eq($joinAlias . '.poll_id', $fromAlias . '.poll_id'), - $qb->expr()->eq($joinAlias . '.poll_option_text', $fromAlias . '.vote_option_text'), + $qb->expr()->eq($joinAlias . '.poll_option_hash', $fromAlias . '.vote_option_hash'), $qb->expr()->eq($joinAlias . '.deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT)), ) ); diff --git a/lib/Helper/Hash.php b/lib/Helper/Hash.php new file mode 100644 index 0000000000..ff5695d717 --- /dev/null +++ b/lib/Helper/Hash.php @@ -0,0 +1,75 @@ + + */ +class AddMissingIndicesListener implements IEventListener { + public function handle(Event $event): void { + if (!($event instanceof AddMissingIndicesEvent)) { + return; + } + + foreach (TableSchema::OPTIONAL_INDICES as $table => $indices) { + foreach ($indices as $name => $definition) { + $event->addMissingIndex( + $table, + $name, + $definition['columns'], + ); + } + } + } +} diff --git a/lib/Migration/RepairSteps/CreateIndices.php b/lib/Migration/RepairSteps/CreateIndices.php index 4d7a31fbba..aa6b825995 100644 --- a/lib/Migration/RepairSteps/CreateIndices.php +++ b/lib/Migration/RepairSteps/CreateIndices.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Db\IndexManager; +use OCA\Polls\Db\Share; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; @@ -27,7 +28,7 @@ public function __construct( } public function getName() { - return 'Polls - Create indices and foreign key constraints'; + return 'Polls - Create all unique and optional indices and foreign key constraints'; } public function run(IOutput $output): void { @@ -36,8 +37,13 @@ public function run(IOutput $output): void { $this->schema = $this->connection->createSchema(); $this->indexManager->setSchema($this->schema); + // remove foreign keys from the share table + // cannot be used anymore since v8.0.0 + $messages = array_merge($messages, $this->indexManager->removeForeignKeysFromTable(Share::TABLE)); + $messages = array_merge($messages, $this->indexManager->createForeignKeyConstraints()); - $messages = array_merge($messages, $this->indexManager->createIndices()); + $messages = array_merge($messages, $this->indexManager->createUniqueIndices()); + $messages = array_merge($messages, $this->indexManager->createOptionalIndices()); $this->connection->migrateToSchema($this->schema); foreach ($messages as $message) { diff --git a/lib/Migration/RepairSteps/Install.php b/lib/Migration/RepairSteps/Install.php index 34062d9926..ae3d9fc1e1 100644 --- a/lib/Migration/RepairSteps/Install.php +++ b/lib/Migration/RepairSteps/Install.php @@ -36,7 +36,7 @@ public function run(IOutput $output): void { $this->indexManager->setSchema($this->schema); $messages = array_merge($messages, $this->indexManager->createForeignKeyConstraints()); - $messages = array_merge($messages, $this->indexManager->createIndices()); + $messages = array_merge($messages, $this->indexManager->createUniqueIndices()); $this->connection->migrateToSchema($this->schema); diff --git a/lib/Migration/TableSchema.php b/lib/Migration/TableSchema.php index 53954cd266..21ead0dff9 100644 --- a/lib/Migration/TableSchema.php +++ b/lib/Migration/TableSchema.php @@ -8,7 +8,6 @@ namespace OCA\Polls\Migration; -use Doctrine\DBAL\Types\Type; use OCA\Polls\Db\Comment; use OCA\Polls\Db\Log; use OCA\Polls\Db\Option; @@ -19,7 +18,6 @@ use OCA\Polls\Db\Subscription; use OCA\Polls\Db\Vote; use OCA\Polls\Db\Watch; -use OCP\DB\ISchemaWrapper; use OCP\DB\Types; /** @@ -29,12 +27,6 @@ */ abstract class TableSchema { - // deprecated since 8.1.0, but keeb these constants to prevent broken updates - // from a version prior to 8.1.0; Fix was implemented with v8.1.2 - public const FK_PARENT_TABLE = Poll::TABLE; - public const FK_CHILD_TABLES = []; - public const FK_OTHER_TABLES = []; - /** * define all foreign key indices * Parentable => [Childable => ['constraintColumn' => 'columnName']] @@ -57,24 +49,78 @@ abstract class TableSchema { /** * define useful common indices, which are not unique * table => ['name' => 'indexName', 'unique' => false, 'columns' => ['column1', 'column2']] + * @deprecated since 8.3.0, use OPTIONAL_INDICES instead */ public const COMMON_INDICES = [ 'polls_polls_owners_non_deleted' => ['table' => Poll::TABLE, 'name' => 'polls_polls_owners_non_deleted', 'unique' => false, 'columns' => ['owner', 'deleted']], ]; + /** + * define useful optional indices, which are not unique + * tableName => [ + * indexName => ['columns' => [column1, column2, ...]], + * ...] + */ + public const OPTIONAL_INDICES = [ + Poll::TABLE => [ + 'polls_polls_owners_non_deleted' => ['columns' => ['owner', 'deleted']], + 'polls_polls_deleted' => ['columns' => ['deleted']], + 'polls_polls_owners' => ['columns' => ['owner']], + ], + PollGroup::RELATION_TABLE => [ + 'polls_groups_polls' => ['columns' => ['poll_id', 'group_id']], + ], + Option::TABLE => [ + 'polls_options' => ['columns' => ['poll_id', 'deleted']], + 'polls_options_hash' => ['columns' => ['poll_id', 'poll_option_hash', 'deleted']], + 'polls_options_owner' => ['columns' => ['poll_id', 'owner']], + ], + Share::TABLE => [ + 'polls_shares_user' => ['columns' => ['poll_id', 'user_id', 'deleted']], + 'polls_shares_types' => ['columns' => ['poll_id', 'type', 'deleted']], + 'polls_group_shares_user' => ['columns' => ['group_id', 'user_id', 'deleted']], + ], + Vote::TABLE => [ + 'polls_votes_answers' => ['columns' => ['poll_id', 'user_id']], + 'polls_votes_user' => ['columns' => ['poll_id', 'vote_answer', 'user_id']], + 'polls_votes_hash' => ['columns' => ['poll_id', 'vote_option_hash', 'deleted']], + ], + ]; + /** * define unique indices, which are not primary keys - * table => ['name' => 'indexName', 'unique' => true, 'columns' => ['column1', 'column2']] + * tableName => [ + * indexName => ['columns' => [column1, column2, ...]], + * ...] */ public const UNIQUE_INDICES = [ - Option::TABLE => ['name' => 'UNIQ_options', 'unique' => true, 'columns' => ['poll_id', 'poll_option_hash', 'timestamp']], - Log::TABLE => ['name' => 'UNIQ_unprocessed', 'unique' => true, 'columns' => ['processed', 'poll_id', 'user_id', 'message_id']], - Subscription::TABLE => ['name' => 'UNIQ_subscription', 'unique' => true, 'columns' => ['poll_id', 'user_id']], - Share::TABLE => ['name' => 'UNIQ_shares', 'unique' => true, 'columns' => ['poll_id', 'group_id', 'user_id']], - Vote::TABLE => ['name' => 'UNIQ_votes', 'unique' => true, 'columns' => ['poll_id', 'user_id', 'vote_option_hash']], - Preferences::TABLE => ['name' => 'UNIQ_preferences', 'unique' => true, 'columns' => ['user_id']], - Watch::TABLE => ['name' => 'UNIQ_watch', 'unique' => true, 'columns' => ['poll_id', 'table', 'session_id']], - PollGroup::RELATION_TABLE => ['name' => 'UNIQ_poll_group_relation', 'unique' => true, 'columns' => ['poll_id', 'group_id']], + Option::TABLE => [ + 'UNIQ_options' => ['columns' => ['poll_id', 'poll_option_hash', 'timestamp']], + 'UNIQ_options_bin' => ['columns' => ['poll_id', 'poll_option_hash', 'timestamp']], + ], + Log::TABLE => [ + 'UNIQ_unprocessed' => ['columns' => ['processed', 'poll_id', 'user_id', 'message_id']], + ], + Subscription::TABLE => [ + 'UNIQ_subscription' => ['columns' => ['poll_id', 'user_id']], + ], + Share::TABLE => [ + 'UNIQ_shares' => ['columns' => ['poll_id', 'group_id', 'user_id']], + 'UNIQ_token' => ['columns' => ['token']], + ], + Vote::TABLE => [ + 'UNIQ_votes' => ['columns' => ['poll_id', 'user_id', 'vote_option_hash']], + 'UNIQ_votes_bin' => ['columns' => ['poll_id', 'user_id', 'vote_option_hash']], + ], + Preferences::TABLE => [ + 'UNIQ_preferences' => ['columns' => ['user_id']], + ], + Watch::TABLE => [ + 'UNIQ_watch' => ['columns' => ['poll_id', 'table', 'session_id']], + ], + PollGroup::RELATION_TABLE => [ + 'UNIQ_poll_group_relation' => ['columns' => ['poll_id', 'group_id']], + ], ]; /** @@ -161,6 +207,12 @@ abstract class TableSchema { 'message', // dropped in 1.07, orphaned // 'processed', // dropped in 8.1, orphaned ], + Option::TABLE => [ + 'poll_option_hash_bin', + ], + Vote::TABLE => [ + 'vote_option_hash_bin', + ], ]; /** @@ -212,7 +264,7 @@ abstract class TableSchema { 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'poll_option_text' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 1024]], - 'poll_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 256]], + 'poll_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 32]], 'timestamp' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'duration' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'order' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], @@ -227,7 +279,7 @@ abstract class TableSchema { 'user_id' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 256]], 'vote_option_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'vote_option_text' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 1024]], - 'vote_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 256]], + 'vote_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 32]], 'vote_answer' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 64]], 'deleted' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], ], @@ -243,8 +295,8 @@ abstract class TableSchema { ], Share::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], - 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => false, 'default' => null, 'length' => 20]], - 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => false, 'default' => null, 'length' => 20]], + 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], + 'group_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => null, 'length' => 20]], 'token' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 64]], 'type' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 64]], 'label' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 256]], @@ -285,46 +337,4 @@ abstract class TableSchema { 'preferences' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 65535]], ], ]; - - /** - * Iterate over tables and make sure, they are created or updated - * according to the currently valid schema - * @psalm-api - */ - public static function createOrUpdateSchema(ISchemaWrapper &$schema): array { - $messages = []; - foreach (self::TABLES as $tableName => $columns) { - $tableCreated = false; - - if ($schema->hasTable($tableName)) { - $messages[] = 'Validating table ' . $tableName; - $table = $schema->getTable($tableName); - } else { - $messages[] = 'Creating table ' . $tableName; - $table = $schema->createTable($tableName); - $tableCreated = true; - } - - foreach ($columns as $columnName => $columnDefinition) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - $column->setOptions($columnDefinition['options']); - if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { - $messages[] = 'Migrating type of ' . $tableName . ', ' . $columnName . ' to ' . $columnDefinition['type']; - $column->setType(Type::getType($columnDefinition['type'])); - } - - // force change to current options definition - $table->modifyColumn($columnName, $columnDefinition['options']); - } else { - $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); - } - } - - if ($tableCreated) { - $table->setPrimaryKey(['id']); - } - } - return $messages; - } } diff --git a/lib/Migration/Version080300Date20250812231603.php b/lib/Migration/Version080300Date20250812231603.php new file mode 100644 index 0000000000..561adb9ee7 --- /dev/null +++ b/lib/Migration/Version080300Date20250812231603.php @@ -0,0 +1,103 @@ +schema = $schemaClosure(); + $messages = $this->createTables(); + + foreach ($messages as $message) { + $output->info('Polls - ' . $message); + }; + + return $this->schema; + } + + /** + * @return string[] + * + * @psalm-return non-empty-list + */ + public function createTable(string $tableName, array $columns): array { + $messages = []; + + if ($this->schema->hasTable($tableName)) { + $table = $this->schema->getTable($tableName); + $messages[] = 'Validating table ' . $table->getName(); + $tableCreated = false; + } else { + $table = $this->schema->createTable($tableName); + $tableCreated = true; + $messages[] = 'Creating table ' . $table->getName(); + } + + foreach ($columns as $columnName => $columnDefinition) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + if (Type::lookupName($column->getType()) !== $columnDefinition['type']) { + $messages[] = 'Migrated type of ' . $table->getName() . '[\'' . $columnName . '\'] from ' . Type::lookupName($column->getType()) . ' to ' . $columnDefinition['type']; + $column->setType(Type::getType($columnDefinition['type'])); + } + $column->setOptions($columnDefinition['options']); + + // force change to current options definition + $table->modifyColumn($columnName, $columnDefinition['options']); + } else { + $table->addColumn($columnName, $columnDefinition['type'], $columnDefinition['options']); + $messages[] = 'Added ' . $table->getName() . ', ' . $columnName . ' (' . $columnDefinition['type'] . ')'; + } + } + + if ($tableCreated) { + $table->setPrimaryKey(['id']); + } + return $messages; + } + + /** + * @return string[] + * + * @psalm-return non-empty-list + */ + public function createTables(): array { + $messages = []; + + foreach (TableSchema::TABLES as $tableName => $columns) { + $messages = array_merge($messages, $this->createTable($tableName, $columns)); + } + return $messages; + } +} diff --git a/lib/Model/UserBase.php b/lib/Model/UserBase.php index 6cbbabc0e7..00a1f8ef52 100644 --- a/lib/Model/UserBase.php +++ b/lib/Model/UserBase.php @@ -11,6 +11,7 @@ use DateTimeZone; use JsonSerializable; use OCA\Polls\Helper\Container; +use OCA\Polls\Helper\Hash; use OCA\Polls\Model\Group\Circle; use OCA\Polls\Model\Group\ContactGroup; use OCA\Polls\Model\Group\Group; @@ -108,8 +109,7 @@ public function getPrincipalUri(): string { * hash the real userId to obfuscate the real userId */ public function getHashedUserId(): string { - // TODO: add a session salt - return hash('md5', $this->id); + return Hash::getUserIdHash($this->id); } /** diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php index 89bd81972c..1ea7320fcb 100644 --- a/lib/Service/CommentService.php +++ b/lib/Service/CommentService.php @@ -36,7 +36,7 @@ public function __construct( */ public function list(int $pollId): array { try { - $this->pollMapper->get($pollId, true, withRoles: true) + $this->pollMapper->get($pollId, true) ->request(Poll::PERMISSION_COMMENT_ADD); } catch (Exception $e) { return []; @@ -66,7 +66,7 @@ public function list(int $pollId): array { * Add comment */ public function add(string $message, int $pollId, ?bool $confidential = false): Comment { - $poll = $this->pollMapper->get($pollId, withRoles: true) + $poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_COMMENT_ADD); if ($poll->getForceConfidentialComments()) { @@ -104,7 +104,7 @@ public function delete(int $commentId, bool $restore = false): Comment { $this->comment = $this->commentMapper->find($commentId); if (!$this->comment->getCurrentUserIsEntityUser()) { - $this->pollMapper->get($this->comment->getPollId(), withRoles: true) + $this->pollMapper->get($this->comment->getPollId()) ->request(Poll::PERMISSION_COMMENT_DELETE); } diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 5630b7bd73..5dc49b944e 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -228,7 +228,7 @@ public function sendAutoReminder(): void { * Send a confirmation mail for the poll to all participants */ public function sendConfirmations(int $pollId): SentResult { - $this->pollMapper->get($pollId, withRoles: true) + $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); $sentResult = new SentResult(); diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index 2ebbde7048..184d70b009 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -59,7 +59,7 @@ public function get(int $optionId): Option { * @psalm-return array */ public function list(int $pollId): array { - $this->getPoll($pollId, Poll::PERMISSION_POLL_VIEW); + $this->getPoll($pollId, Poll::PERMISSION_POLL_ACCESS); try { $this->options = $this->optionMapper->findByPoll($pollId, !$this->poll->getIsAllowed(Poll::PERMISSION_POLL_RESULTS_VIEW)); @@ -133,7 +133,7 @@ public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = fal // Option already exists, so we need to update the existing one // and remove deleted setting - $option = $this->optionMapper->findByPollAndText($pollId, $newOption->getPollOptionText(), true); + $option = $this->optionMapper->findByPollAndText($pollId, $newOption->getPollOptionHash(), true); $option->setDeleted(0); $newOption = $this->optionMapper->update($option); @@ -199,7 +199,7 @@ public function delete(int $optionId, bool $restore = false): Option { $option = $this->optionMapper->find($optionId); if (!$option->getCurrentUserIsEntityUser()) { - $this->pollMapper->get($option->getPollId(), withRoles: true) + $this->pollMapper->get($option->getPollId()) ->request(Poll::PERMISSION_OPTION_DELETE); } @@ -331,8 +331,8 @@ public function shift(int $pollId, int $step, string $unit): array { * Copy options from $fromPoll to $toPoll */ public function clone(int $fromPollId, int $toPollId): void { - $this->pollMapper->get($fromPollId, withRoles: true) - ->request(Poll::PERMISSION_POLL_VIEW) + $this->pollMapper->get($fromPollId) + ->request(Poll::PERMISSION_POLL_ACCESS) ->request(Poll::PERMISSION_OPTION_ADD); foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) { @@ -442,9 +442,9 @@ private function moveModifier(int $moveFrom, int $moveTo, int $currentPosition): * * @return void */ - private function getPoll(int $pollId, string $permission = Poll::PERMISSION_POLL_VIEW): void { + private function getPoll(int $pollId, string $permission = Poll::PERMISSION_POLL_ACCESS): void { if ($this->poll->getId() !== $pollId) { - $this->poll = $this->pollMapper->get($pollId, true, withRoles: true); + $this->poll = $this->pollMapper->get($pollId, true); } $this->poll->request($permission); } diff --git a/lib/Service/PollGroupService.php b/lib/Service/PollGroupService.php index 3b62142a43..03b10503e7 100644 --- a/lib/Service/PollGroupService.php +++ b/lib/Service/PollGroupService.php @@ -64,7 +64,7 @@ public function addPollToPollGroup( ?int $pollGroupId = null, ?string $pollGroupName = null, ): PollGroup { - $poll = $this->pollMapper->get($pollId, withRoles: true) + $poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); // Without poll group id, we create a new poll group @@ -112,7 +112,7 @@ public function removePollFromPollGroup( int $pollId, int $pollGroupId, ): ?PollGroup { - $poll = $this->pollMapper->get($pollId, withRoles: true) + $poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); $pollGroup = $this->pollGroupMapper->find($pollGroupId); diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index e17b631aa7..d3975b2880 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -60,7 +60,7 @@ public function listPolls(): array { } return array_values(array_filter($pollList, function (Poll $poll): bool { - return $poll->getIsAllowed(Poll::PERMISSION_POLL_VIEW); + return $poll->getIsAllowed(Poll::PERMISSION_POLL_ACCESS); })); } @@ -74,7 +74,7 @@ public function search(ISearchQuery $query): array { foreach ($polls as $poll) { try { - $poll->request(Poll::PERMISSION_POLL_VIEW); + $poll->request(Poll::PERMISSION_POLL_ACCESS); $pollList[] = $poll; } catch (ForbiddenException $e) { continue; @@ -139,7 +139,7 @@ public function takeover(int $pollId, ?UserBase $targetUser = null): Poll { */ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll { if (!($poll instanceof Poll)) { - $poll = $this->pollMapper->get($poll, withRoles: true); + $poll = $this->pollMapper->get($poll); } $poll->request(Poll::PERMISSION_POLL_CHANGE_OWNER); @@ -168,14 +168,10 @@ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll * get poll configuration * @return Poll */ - public function get(int $pollId, $lightweight = false) { + public function get(int $pollId) { try { - if ($lightweight) { - $this->poll = $this->pollMapper->get($pollId, withRoles: true); - } else { - $this->poll = $this->pollMapper->find($pollId); - } - $this->poll->request(Poll::PERMISSION_POLL_VIEW); + $this->poll = $this->pollMapper->get($pollId); + $this->poll->request(Poll::PERMISSION_POLL_ACCESS); return $this->poll; } catch (DoesNotExistException $e) { throw new NotFoundException('Poll not found'); @@ -184,7 +180,7 @@ public function get(int $pollId, $lightweight = false) { public function getPollOwnerFromDB(int $pollId): UserBase { try { - $poll = $this->pollMapper->get($pollId, withRoles: true); + $poll = $this->pollMapper->get($pollId); return $poll->getUser(); } catch (DoesNotExistException $e) { throw new NotFoundException('Poll not found'); @@ -218,7 +214,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V // create new poll before resetting all values to // ensure that the poll has all required values and an id - // latter checks mai fail if the poll has no id + // later checks may fail if the poll has no id $this->poll = $this->pollMapper->insert($this->poll); $this->poll->setDescription(''); @@ -248,7 +244,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V * @psalm-return array{poll: Poll, diff: array, changes: array} */ public function update(int $pollId, array $pollConfiguration): array { - $this->poll = $this->pollMapper->get($pollId, withRoles: true) + $this->poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); // Validate valuess @@ -303,7 +299,7 @@ public function update(int $pollId, array $pollConfiguration): array { * @return Poll */ public function lockAnonymous(int $pollId): Poll { - $this->poll = $this->pollMapper->find($pollId); + $this->poll = $this->pollMapper->get($pollId); // Only possible, if poll is already anonymized if ($this->poll->getAnonymous() < 1) { @@ -336,7 +332,7 @@ public function setLastInteraction(int $pollId): void { * @return Poll */ public function toggleArchive(int $pollId): Poll { - $this->poll = $this->pollMapper->find($pollId) + $this->poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_DELETE); $this->poll->setDeleted($this->poll->getDeleted() ? 0 : time()); @@ -357,7 +353,7 @@ public function toggleArchive(int $pollId): Poll { */ public function delete(int $pollId): Poll { try { - $this->poll = $this->pollMapper->get($pollId, withRoles: true) + $this->poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_DELETE); } catch (DoesNotExistException $e) { throw new AlreadyDeletedException('Poll not found, assume already deleted'); @@ -374,7 +370,7 @@ public function delete(int $pollId): Poll { * @return Poll */ public function close(int $pollId): Poll { - $this->pollMapper->get($pollId, withRoles: true) + $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); return $this->toggleClose($pollId, time() - 5); } @@ -384,7 +380,7 @@ public function close(int $pollId): Poll { * @return Poll */ public function reopen(int $pollId): Poll { - $this->pollMapper->get($pollId, withRoles: true) + $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); return $this->toggleClose($pollId, 0); } @@ -394,7 +390,7 @@ public function reopen(int $pollId): Poll { * @return Poll */ private function toggleClose(int $pollId, int $expiry): Poll { - $this->poll = $this->pollMapper->find($pollId) + $this->poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); $this->poll->setExpire($expiry); @@ -414,8 +410,8 @@ private function toggleClose(int $pollId, int $expiry): Poll { * @return Poll */ public function clone(int $pollId): Poll { - $origin = $this->pollMapper->get($pollId, withRoles: true) - ->request(Poll::PERMISSION_POLL_VIEW); + $origin = $this->pollMapper->get($pollId) + ->request(Poll::PERMISSION_POLL_ACCESS); $this->appSettings->getPollCreationAllowed(); $this->poll = new Poll(); @@ -446,7 +442,7 @@ public function clone(int $pollId): Poll { * */ public function getParticipantsEmailAddresses(int $pollId): array { - $this->poll = $this->pollMapper->get($pollId, withRoles: true) + $this->poll = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); $votes = $this->voteMapper->findParticipantsByPoll($this->poll->getId()); diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index de83055634..0e54fb0eea 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -88,7 +88,7 @@ public function __construct( public function list(int $pollOrPollGroupId, string $purpose = 'poll'): array { try { if ($purpose === 'poll') { - $poll = $this->pollMapper->get($pollOrPollGroupId, withRoles: true) + $poll = $this->pollMapper->get($pollOrPollGroupId) ->request(Poll::PERMISSION_POLL_EDIT); $this->shares = $this->shareMapper->findByPoll($pollOrPollGroupId, $poll->getPollGroups()); } else { @@ -113,7 +113,7 @@ public function list(int $pollOrPollGroupId, string $purpose = 'poll'): array { */ public function listNotInvited(int $pollId): array { try { - $this->pollMapper->get($pollId, withRoles: true) + $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_EDIT); $this->shares = $this->shareMapper->findByPollNotInvited($pollId); } catch (ForbiddenException $e) { @@ -130,7 +130,7 @@ public function listNotInvited(int $pollId): array { * If user is not authorized for this poll, create a personal share * for this user and return the created share instead of the public share */ - private function convertPublicShareToPersonalShare(): void { + private function convertPublicShareToPersonalShare(): Share { try { $this->share = $this->createNewShare( $this->share->getPollId(), @@ -144,51 +144,57 @@ private function convertPublicShareToPersonalShare(): void { // remove the public token from session $this->userSession->setShareToken($this->share->getToken()); } + return $this->share; } + /** - * Get share by token for accessing the poll + * Request access to a share by share token * * @param string $token Token of share to get */ public function request(string $token): Share { - $this->share = $this->shareMapper->findByToken($token); $this->validateShareType(); + $poll = $this->pollMapper->get($this->share->getPollId()); + // deletes the displayname, to avoid displayname preset in case of public polls if ($this->share->getType() === Share::TYPE_PUBLIC) { $this->share->setDisplayName(''); } - $poll = $this->pollMapper->get($this->share->getPollId(), withRoles: true); - if ($poll->getIsAllowed(Poll::PERMISSION_VOTE_EDIT) - || $poll->getIsAllowed(Poll::PERMISSION_POLL_EDIT) - ) { - // user is allowed to access the poll, continue without creating a new share + if ($poll->getIsInvolved()) { + // user is already involved in the poll + if ($poll->getShareToken()) { + // return personal share, if exists + return $this->shareMapper->findByToken($poll->getShareToken()); + } + // Otherwise return the requested share return $this->share; } - if (!$poll->getIsAllowed(Poll::PERMISSION_POLL_VIEW)) { - throw new ForbiddenException('User is not allowed to access this poll'); - } - // Exception: logged in user, accesses the poll via public share link - if ($this->share->getType() === Share::TYPE_PUBLIC && $this->userSession->getIsLoggedIn()) { - $this->convertPublicShareToPersonalShare(); + if ($this->share->getType() === Share::TYPE_PUBLIC) { + // Exception: logged in user, accesses the poll via public share link + if ($this->userSession->getIsLoggedIn()) { + return $this->convertPublicShareToPersonalShare(); + } + return $this->share; } + // Exception for convertable (email and contact) shares if (in_array($this->share->getType(), Share::CONVERATABLE_PUBLIC_SHARES, true)) { - $this->convertPersonalPublicShareToExternalShare(); + return $this->convertPersonalPublicShareToExternalShare(); } return $this->share; } /** - * Get share by token for accessing the poll + * Get share by token without access check * * @param string $token Token of share to get */ @@ -201,7 +207,7 @@ public function get(string $token): Share { */ public function setType(string $token, string $type): Share { $this->share = $this->shareMapper->findByToken($token); - $this->pollMapper->get($this->share->getPollId(), withRoles: true) + $this->pollMapper->get($this->share->getPollId()) ->request(Poll::PERMISSION_POLL_EDIT); // ATM only type user can transform to type admin and vice versa @@ -221,7 +227,7 @@ public function setType(string $token, string $type): Share { public function setPublicPollEmail(string $token, string $value): Share { try { $this->share = $this->shareMapper->findByToken($token); - $this->pollMapper->get($this->share->getPollId(), withRoles: true) + $this->pollMapper->get($this->share->getPollId()) ->request(Poll::PERMISSION_POLL_EDIT); $this->share->setPublicPollEmail($value); $this->share = $this->shareMapper->update($this->share); @@ -285,7 +291,7 @@ public function setLabel(string $label, string $token): Share { $this->share = $this->shareMapper->findByToken($token); if ($this->share->getType() === Share::TYPE_PUBLIC) { - $this->pollMapper->get($this->share->getPollId(), withRoles: true) + $this->pollMapper->get($this->share->getPollId()) ->request(Poll::PERMISSION_POLL_EDIT); $this->share->setLabel($label); @@ -305,14 +311,14 @@ public function setLabel(string $label, string $token): Share { } /** - * Delete emailAddress of the personal share + * Delete email address of the personal share */ public function deleteEmailAddress(Share $share): Share { if ($share->getType() === Share::TYPE_EXTERNAL) { $share->setEmailAddress(''); return $this->shareMapper->update($share); } else { - throw new InvalidShareTypeException('Email address can only be set in external shares.'); + throw new InvalidShareTypeException('Email address can only be removed from external shares.'); } } @@ -330,11 +336,11 @@ private function convertPersonalPublicShareToExternalShare( ?string $emailAddress = null, ?string $timeZone = null, ?string $language = null, - ): void { + ): Share { // paranoia double check if (!in_array($this->share->getType(), Share::CONVERATABLE_PUBLIC_SHARES, true)) { - return; + throw new InvalidShareTypeException('Cannot convert share type ' . $this->share->getType() . ' to external share'); } $initialUserId = $this->share->getUserId(); @@ -357,6 +363,7 @@ private function convertPersonalPublicShareToExternalShare( $this->share->setUserId($this->generatePublicUserId()); $this->share = $this->shareMapper->update($this->share); $this->convertDependingObjects($initialUserId, $this->share->getUserId(), $this->share->getPollId()); + return $this->share; } /** @@ -453,7 +460,7 @@ public function delete(Share $share, bool $restore = false): Share { $this->pollGroupMapper->find($share->getGroupId()) ->request(PollGroup::PERMISSION_POLL_GROUP_EDIT); } else { - $this->pollMapper->get($share->getPollId(), withRoles: true) + $this->pollMapper->get($share->getPollId()) ->request(Poll::PERMISSION_POLL_EDIT); } @@ -483,7 +490,7 @@ public function lockByToken(string $token, bool $unlock = false): Share { * @param bool $unlock Set true, if share is to be unlocked */ private function lock(Share $share, bool $unlock = false): Share { - $this->pollMapper->get($share->getPollId(), withRoles: true) + $this->pollMapper->get($share->getPollId()) ->request(Poll::PERMISSION_POLL_EDIT); $share->setLocked($unlock ? 0 : time()); @@ -606,7 +613,7 @@ public function add( string $purpose = 'poll', ): Share { if ($purpose === 'poll') { - $poll = $this->pollMapper->get($pollOrPollGroupId, withRoles: true) + $poll = $this->pollMapper->get($pollOrPollGroupId) ->request(Poll::PERMISSION_POLL_EDIT) ->request(Poll::PERMISSION_SHARE_ADD); @@ -660,21 +667,30 @@ private function sortByCategory(): void { } /** - * Validate if share type is allowed to be used in a public poll - * or is accessibale for use by the current user + * Validate if share type is allowed to be used to acess a poll + * or is accessible for use by the current user */ private function validateShareType(): void { $valid = match ($this->share->getType()) { - Share::TYPE_PUBLIC, Share::TYPE_EMAIL, Share::TYPE_EXTERNAL => true, - Share::TYPE_USER => $this->share->getUserId() === $this->userSession->getCurrentUserId(), - Share::TYPE_ADMIN => $this->share->getUserId() === $this->userSession->getCurrentUserId(), + // Public shares are always allowed + Share::TYPE_PUBLIC => true, + + // External shares are only allowed, if the user is not logged in + Share::TYPE_CONTACT, Share::TYPE_EMAIL, Share::TYPE_EXTERNAL + => !$this->userSession->getIsLoggedIn(), + + // User and Admin shares are only allowed, if the user is logged in and the share belongs to the current user + Share::TYPE_USER, Share::TYPE_ADMIN + => $this->userSession->getIsLoggedIn() && $this->share->getUserId() === $this->userSession->getCurrentUserId(), + // Note: $this->share->getUserId() is actually the group name in case of Share::TYPE_GROUP - Share::TYPE_GROUP => $this->userSession->getCurrentUser()->getIsInGroup($this->share->getUserId()), + Share::TYPE_GROUP => $this->userSession->getIsLoggedIn() && $this->userSession->getCurrentUser()->getIsInGroup($this->share->getUserId()), default => throw new ForbiddenException("Invalid share type {$this->share->getType()}"), }; + if (!$valid) { - throw new ForbiddenException("User is not allowed to use this share for poll access ({$this->share->getType()})"); + throw new ForbiddenException("User is not allowed to use this share ({$this->share->getType()})"); } } diff --git a/lib/Service/SubscriptionService.php b/lib/Service/SubscriptionService.php index f2ec26fc25..b6e93f7296 100644 --- a/lib/Service/SubscriptionService.php +++ b/lib/Service/SubscriptionService.php @@ -27,8 +27,7 @@ public function __construct( } public function get(int $pollId): bool { - $this->pollMapper->get($pollId, true, withRoles: true) - ->request(Poll::PERMISSION_POLL_VIEW); + $this->pollMapper->get($pollId, true)->request(Poll::PERMISSION_POLL_ACCESS); try { $this->subscriptionMapper->findByPollAndUser($pollId, $this->userSession->getCurrentUserId()); @@ -53,9 +52,7 @@ public function set(bool $setToSubscribed, int $pollId): bool { } } else { try { - // $this->pollMapper->get($pollId, withRoles: true); - $this->pollMapper->get($pollId, withRoles: true) - ->request(Poll::PERMISSION_POLL_SUBSCRIBE); + $this->pollMapper->get($pollId)->request(Poll::PERMISSION_POLL_SUBSCRIBE); $this->add($pollId, $this->userSession->getCurrentUserId()); } catch (ForbiddenException $e) { return false; diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index 26ccc0146c..35c7069395 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -40,8 +40,8 @@ public function __construct( * @return Vote[] */ public function list(int $pollId): array { - $poll = $this->pollMapper->get($pollId, true, withRoles: true) - ->request(Poll::PERMISSION_POLL_VIEW); + $poll = $this->pollMapper->get($pollId, true) + ->request(Poll::PERMISSION_POLL_ACCESS); try { $poll->request(Poll::PERMISSION_POLL_RESULTS_VIEW); @@ -87,7 +87,7 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote } else { $option = $this->optionMapper->find($optionOrOptionIdoptionId); } - $poll = $this->pollMapper->get($option->getPollId(), withRoles: true) + $poll = $this->pollMapper->get($option->getPollId()) ->request(Poll::PERMISSION_VOTE_EDIT); if ($option->getIsLocked()) { @@ -152,7 +152,7 @@ public function deleteUserFromPoll(int $pollId, string $userId = '', bool $delet $checkRight = Poll::PERMISSION_VOTE_EDIT; } - $this->pollMapper->get($pollId, withRoles: true)->request($checkRight); + $this->pollMapper->get($pollId)->request($checkRight); return $this->delete($pollId, $userId, $deleteOnlyOrphaned); } diff --git a/lib/Service/WatchService.php b/lib/Service/WatchService.php index 2ae2c1e328..bb8bc8272c 100644 --- a/lib/Service/WatchService.php +++ b/lib/Service/WatchService.php @@ -36,8 +36,8 @@ public function __construct( */ public function watchUpdates(int $pollId, string $mode, ?int $offset = null): array { if ($pollId) { - $this->pollMapper->get($pollId, true, withRoles: true) - ->request(Poll::PERMISSION_POLL_VIEW); + $this->pollMapper->get($pollId, true) + ->request(Poll::PERMISSION_POLL_ACCESS); } $start = time(); diff --git a/lib/UserSession.php b/lib/UserSession.php index 26a5ca30be..10be5077ec 100644 --- a/lib/UserSession.php +++ b/lib/UserSession.php @@ -12,6 +12,7 @@ use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; use OCA\Polls\Db\UserMapper; +use OCA\Polls\Helper\Hash; use OCA\Polls\Model\User\Cron; use OCA\Polls\Model\User\Ghost; use OCA\Polls\Model\UserBase; @@ -173,7 +174,7 @@ public function getClientId(): string { } public function getClientIdHashed(): string { - return hash('md5', $this->getClientId()); + return Hash::getClientIdHash($this->getClientId()); } public function setClientId(string $clientId): void { diff --git a/src/components/AppIcons/PollsAppIcon.vue b/src/components/AppIcons/PollsAppIcon.vue index 9195475583..f8138e2795 100644 --- a/src/components/AppIcons/PollsAppIcon.vue +++ b/src/components/AppIcons/PollsAppIcon.vue @@ -27,9 +27,9 @@ const { :height="size" viewBox="0 0 32 32"> - - - + + + {{ title }} diff --git a/src/components/Poll/PollInfoLine.vue b/src/components/Poll/PollInfoLine.vue index 103b20505a..d84aead42c 100644 --- a/src/components/Poll/PollInfoLine.vue +++ b/src/components/Poll/PollInfoLine.vue @@ -128,7 +128,7 @@ const subTexts = computed(() => { }) const dateCreatedRelative = computed(() => - pollStore.getExpirationDateTime.toRelative(), + pollStore.getCreationDateTime.toRelative(), ) const closeToClosing = computed( diff --git a/tests/Unit/Db/PollMapperTest.php b/tests/Unit/Db/PollMapperTest.php index 688800543e..f85ac5d772 100644 --- a/tests/Unit/Db/PollMapperTest.php +++ b/tests/Unit/Db/PollMapperTest.php @@ -57,7 +57,7 @@ public function testUpdate() { */ public function testFind() { foreach ($this->polls as $poll) { - $this->assertInstanceOf(Poll::class, $this->pollMapper->find($poll->getId())); + $this->assertInstanceOf(Poll::class, $this->pollMapper->get($poll->getId())); } } diff --git a/tests/Unit/Factories/PollFactory.php b/tests/Unit/Factories/PollFactory.php index 8893d25200..7ce5f343ba 100644 --- a/tests/Unit/Factories/PollFactory.php +++ b/tests/Unit/Factories/PollFactory.php @@ -5,28 +5,30 @@ */ use League\FactoryMuffin\Faker\Facade as Faker; - +use OCA\Polls\Db\Poll; /** * General factory for the poll model. */ $fm->define('OCA\Polls\Db\Poll')->setDefinitions([ 'type' => 'textPoll', 'title' => Faker::text(124), + 'votingVariant' => Poll::VARIANT_SIMPLE, 'description' => Faker::text(255), 'owner' => Faker::firstNameMale(), 'created' => function () { $date = new DateTime('today'); return $date->getTimestamp(); }, - 'expire' => function () { - $date = new DateTime('tomorrow'); + 'lastInteraction' => function () { + $date = new DateTime('today'); return $date->getTimestamp(); }, - 'deleted' => function () { - $date = new DateTime('+1 month'); + 'expire' => function () { + $date = new DateTime('tomorrow'); return $date->getTimestamp(); }, - 'access' => 'public', + 'deleted' => 0, + 'access' => Poll::ACCESS_OPEN, 'anonymous' => 0, 'allowComment' => 1, 'allowMaybe' => 1, @@ -37,7 +39,7 @@ }, 'voteLimit' => 0, 'optionLimit' => 0, - 'showResults' => 'always', + 'showResults' => Poll::SHOW_RESULTS_ALWAYS, 'adminAccess' => 0, 'hideBookedUp' => 0, 'useNo' => 0,