diff --git a/app/config/services.yml b/app/config/services.yml index 3cca8b30f..f0acf08a2 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -31,6 +31,7 @@ services: $supportUrl: '@=service(''craue_config'').get(''wallabag_support_url'')' $fonts: '%wallabag.fonts%' $defaultIgnoreOriginInstanceRules: '%wallabag.default_ignore_origin_instance_rules%' + $entryDeletionExpirationDays: '%wallabag.entry_deletion_expiration_days%' Wallabag\: resource: '../../src/*' @@ -253,6 +254,11 @@ services: Wallabag\Helper\DownloadImages: arguments: $baseFolder: "%kernel.project_dir%/web/assets/images" + + + Wallabag\Helper\EntryDeletionExpirationConfig: + arguments: + $defaultExpirationDays: '%wallabag.entry_deletion_expiration_days%' Wallabag\Command\InstallCommand: arguments: diff --git a/app/config/wallabag.yml b/app/config/wallabag.yml index 02106a33b..bef3ae4c0 100644 --- a/app/config/wallabag.yml +++ b/app/config/wallabag.yml @@ -32,6 +32,7 @@ parameters: wallabag.action_mark_as_read: 1 wallabag.list_mode: 0 wallabag.display_thumbnails: 1 + wallabag.entry_deletion_expiration_days: 90 wallabag.fetching_error_message_title: 'No title found' wallabag.fetching_error_message: | wallabag can't retrieve contents for this article. Please troubleshoot this issue. diff --git a/composer.json b/composer.json index fd95d1d4a..0bd37380b 100644 --- a/composer.json +++ b/composer.json @@ -156,7 +156,8 @@ "wallabag/rulerz": "dev-master", "wallabag/rulerz-bundle": "dev-master", "willdurand/hateoas": "^3.12", - "willdurand/hateoas-bundle": "^2.7" + "willdurand/hateoas-bundle": "^2.7", + "zircote/swagger-php": "^4.11.1 || ^5.0" }, "require-dev": { "dama/doctrine-test-bundle": "^8.2.2", diff --git a/composer.lock b/composer.lock index 122a3321b..36d6e1ea7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "00b8f95df6ec0572c06ad6f34847405c", + "content-hash": "b47338ba84f717112fd3f36632c65e03", "packages": [ { "name": "babdev/pagerfanta-bundle", @@ -19449,6 +19449,6 @@ "ext-tokenizer": "*", "ext-xml": "*" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/fixtures/EntryDeletionFixtures.php b/fixtures/EntryDeletionFixtures.php new file mode 100644 index 000000000..5547b8f29 --- /dev/null +++ b/fixtures/EntryDeletionFixtures.php @@ -0,0 +1,53 @@ +getReference('admin-user', User::class); + $bobUser = $this->getReference('bob-user', User::class); + + $deletions = [ + [ + 'user' => $adminUser, + 'entry_id' => 1004, + 'deleted_at' => new \DateTime('-4 day'), + ], + [ + 'user' => $adminUser, + 'entry_id' => 1001, + 'deleted_at' => new \DateTime('-1 day'), + ], + [ + 'user' => $bobUser, + 'entry_id' => 1003, + 'deleted_at' => new \DateTime('-3 days'), + ], + ]; + + foreach ($deletions as $deletionData) { + $deletion = new EntryDeletion($deletionData['user'], $deletionData['entry_id']); + $deletion->setDeletedAt($deletionData['deleted_at']); + + $manager->persist($deletion); + } + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + UserFixtures::class, + EntryFixtures::class, + ]; + } +} diff --git a/migrations/Version20250530183653.php b/migrations/Version20250530183653.php new file mode 100644 index 000000000..4f8eb201c --- /dev/null +++ b/migrations/Version20250530183653.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + CREATE TABLE "wallabag_entry_deletion" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, entry_id INTEGER NOT NULL, deleted_at DATETIME NOT NULL, CONSTRAINT FK_D91765D5A76ED395 FOREIGN KEY (user_id) REFERENCES "wallabag_user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_D91765D5A76ED395 ON "wallabag_entry_deletion" (user_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_D91765D54AF38FD1 ON "wallabag_entry_deletion" (deleted_at) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_D91765D5A76ED3954AF38FD1 ON "wallabag_entry_deletion" (user_id, deleted_at) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP TABLE "wallabag_entry_deletion" + SQL); + } +} diff --git a/src/Command/PurgeEntryDeletionsCommand.php b/src/Command/PurgeEntryDeletionsCommand.php new file mode 100644 index 000000000..4c76d8d3a --- /dev/null +++ b/src/Command/PurgeEntryDeletionsCommand.php @@ -0,0 +1,77 @@ +addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Do not actually delete records, just show what would be deleted' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $dryRun = (bool) $input->getOption('dry-run'); + + $cutoff = $this->expirationConfig->getCutoffDate(); + $count = $this->entryDeletionRepository->countAllBefore($cutoff); + + if ($dryRun) { + $io->text('Dry run mode enabled (no records will be deleted)'); + if (0 === $count) { + $io->success('No entry deletion records found.'); + } else { + $io->success(\sprintf('Would have deleted %d records.', $count)); + } + + return 0; + } + + if (0 === $count) { + $io->success('No entry deletion records found.'); + + return 0; + } + + $confirmMessage = \sprintf( + 'Are you sure you want to delete records older than %s? (count: %d)', + $cutoff->format('Y-m-d'), + $count, + ); + if (!$io->confirm($confirmMessage)) { + return 0; + } + + $this->entryDeletionRepository->deleteAllBefore($cutoff); + + $io->success(\sprintf('Successfully deleted %d records.', $count)); + + return 0; + } +} diff --git a/src/Controller/Api/EntryDeletionRestController.php b/src/Controller/Api/EntryDeletionRestController.php new file mode 100644 index 000000000..29d8edacd --- /dev/null +++ b/src/Controller/Api/EntryDeletionRestController.php @@ -0,0 +1,118 @@ + 'json'])] + #[OA\Get( + summary: 'Retrieve all entry deletions for the current user.', + tags: ['EntryDeletions'], + parameters: [ + new OA\Parameter( + name: 'since', + in: 'query', + description: 'The timestamp (in seconds) since when you want entry deletions.', + required: false, + schema: new OA\Schema(type: 'integer') + ), + new WOA\OrderParameter(), + new WOA\PagerFanta\PageParameter(), + new WOA\PagerFanta\PerPageParameter(default: 100), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Returned when successful.', + content: new WOA\PagerFanta\JsonContent(EntryDeletion::class) + ), + new OA\Response( + response: 410, + description: 'Returned when the since date is before the cutoff date.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Property(type: 'string'), + ] + ), + headers: [ + new OA\Header( + header: 'X-Wallabag-Entry-Deletion-Cutoff', + description: 'The furthest cutoff timestamp possible for entry deletions.', + schema: new OA\Schema(type: 'integer') + ), + ] + ), + ] + )] + #[IsGranted('LIST_ENTRIES')] + public function getEntryDeletionsAction( + Request $request, + EntryDeletionRepository $entryDeletionRepository, + EntryDeletionExpirationConfig $expirationConfig, + ) { + $this->validateAuthentication(); + $userId = $this->getUser()->getId(); + + $page = $request->query->getInt('page', 1); + $perPage = $request->query->getInt('perPage', 100); + $order = strtolower($request->query->get('order', 'desc')); + $since = $request->query->getInt('since', 0); + + if (!\in_array($order, ['asc', 'desc'], true)) { + $order = 'desc'; + } + + if (0 < $since) { + $cutoff = $expirationConfig->getCutoffDate()->getTimestamp(); + if ($since < $cutoff) { + // Using a JSON response rather than a GoneHttpException to preserve the error message in production + return $this->json( + [ + 'message' => "The requested since date ({$since}) is before the data retention cutoff date ({$cutoff}).\n" + . 'You can get the cutoff date programmatically from the X-Wallabag-Entry-Deletion-Cutoff header.', + ], + 410, + headers: [ + 'X-Wallabag-Entry-Deletion-Cutoff' => $cutoff, + ] + ); + } + } + + $pager = $entryDeletionRepository->findEntryDeletions($userId, $since, $order); + $pager->setMaxPerPage($perPage); + $pager->setCurrentPage($page); + + $pagerfantaFactory = new PagerfantaFactory('page', 'perPage'); + $paginatedCollection = $pagerfantaFactory->createRepresentation( + $pager, + new HateoasRoute( + 'api_get_entry_deletions', + [ + 'page' => $page, + 'perPage' => $perPage, + 'order' => $order, + 'since' => $since, + ], + true + ), + ); + + return $this->sendResponse($paginatedCollection); + } +} diff --git a/src/Entity/EntryDeletion.php b/src/Entity/EntryDeletion.php new file mode 100644 index 000000000..95f1d4c39 --- /dev/null +++ b/src/Entity/EntryDeletion.php @@ -0,0 +1,107 @@ +user = $user; + $this->entryId = $entryId; + $this->deletedAt = new \DateTime(); + } + + /** + * Get id. + * + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Get entryId. + * + * @return int + */ + public function getEntryId() + { + return $this->entryId; + } + + /** + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * @return \DateTimeInterface + */ + public function getDeletedAt() + { + return $this->deletedAt; + } + + /** + * Set deleted_at. + * + * @return EntryDeletion + */ + public function setDeletedAt(\DateTimeInterface $deletedAt) + { + $this->deletedAt = $deletedAt; + + return $this; + } + + /** + * Create an EntryDeletion from an Entry that's being deleted. + */ + public static function createFromEntry(Entry $entry): self + { + return new self($entry->getUser(), $entry->getId()); + } +} diff --git a/src/Event/Subscriber/EntryDeletionSubscriber.php b/src/Event/Subscriber/EntryDeletionSubscriber.php new file mode 100644 index 000000000..e9252559e --- /dev/null +++ b/src/Event/Subscriber/EntryDeletionSubscriber.php @@ -0,0 +1,36 @@ + 'onEntryDeleted', + ]; + } + + /** + * Create a deletion event record for the entry. + */ + public function onEntryDeleted(EntryDeletedEvent $event): void + { + $entry = $event->getEntry(); + + $deletionEvent = EntryDeletion::createFromEntry($entry); + + $this->em->persist($deletionEvent); + $this->em->flush(); + } +} diff --git a/src/Helper/EntryDeletionExpirationConfig.php b/src/Helper/EntryDeletionExpirationConfig.php new file mode 100644 index 000000000..55d981693 --- /dev/null +++ b/src/Helper/EntryDeletionExpirationConfig.php @@ -0,0 +1,34 @@ +expirationDays = $defaultExpirationDays; + } + + public function getCutoffDate(): \DateTime + { + return new \DateTime("-{$this->expirationDays} days"); + } + + public function getExpirationDays(): int + { + return $this->expirationDays; + } + + /** + * Override the expiration days parameter. + * This is mostly useful for testing purposes and should not be used in other contexts. + */ + public function setExpirationDays(int $days): self + { + $this->expirationDays = $days; + + return $this; + } +} diff --git a/src/OpenApi/Attribute/OrderParameter.php b/src/OpenApi/Attribute/OrderParameter.php new file mode 100644 index 000000000..2bd856060 --- /dev/null +++ b/src/OpenApi/Attribute/OrderParameter.php @@ -0,0 +1,22 @@ +createQueryBuilder('de') + ->where('de.user = :userId')->setParameter('userId', $userId) + ->orderBy('de.deletedAt', $order); + + if ($since > 0) { + $qb->andWhere('de.deletedAt >= :since') + ->setParameter('since', new \DateTime(date('Y-m-d H:i:s', $since))); + } + + $pagerAdapter = new DoctrineORMAdapter($qb, true, false); + $pager = new Pagerfanta($pagerAdapter); + + return $pager; + } + + public function countAllBefore(\DateTime $date): int + { + return $this->createQueryBuilder('de') + ->select('COUNT(de.id)') + ->where('de.deletedAt < :date') + ->setParameter('date', $date) + ->getQuery() + ->getSingleScalarResult(); + } + + public function deleteAllBefore(\DateTime $date) + { + $this->createQueryBuilder('de') + ->delete() + ->where('de.deletedAt < :date') + ->setParameter('date', $date) + ->getQuery() + ->execute(); + } +} diff --git a/src/Security/Voter/EntryDeletionVoter.php b/src/Security/Voter/EntryDeletionVoter.php new file mode 100644 index 000000000..becd90209 --- /dev/null +++ b/src/Security/Voter/EntryDeletionVoter.php @@ -0,0 +1,43 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + return match ($attribute) { + self::VIEW, self::LIST => $user === $subject->getUser(), + default => false, + }; + } +} diff --git a/tests/Command/PurgeEntryDeletionsCommandTest.php b/tests/Command/PurgeEntryDeletionsCommandTest.php new file mode 100644 index 000000000..53a472e72 --- /dev/null +++ b/tests/Command/PurgeEntryDeletionsCommandTest.php @@ -0,0 +1,86 @@ +expirationConfig = self::getContainer()->get(EntryDeletionExpirationConfig::class); + $this->expirationConfig->setExpirationDays(2); + + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->entryDeletionRepository = $em->getRepository(EntryDeletion::class); + } + + public function testRunPurgeEntryDeletionsCommandWithDryRun() + { + $application = new Application(self::$kernel); + $command = $application->find('wallabag:purge-entry-deletions'); + + $tester = new CommandTester($command); + $tester->execute([ + '--dry-run' => true, + ]); + + $this->assertStringContainsString('Dry run mode enabled', $tester->getDisplay()); + $this->assertSame(0, $tester->getStatusCode()); + + $count = $this->entryDeletionRepository->countAllBefore($this->expirationConfig->getCutoffDate()); + $this->assertSame(2, $count); + } + + public function testRunPurgeEntryDeletionsCommand() + { + $application = new Application(self::$kernel); + $command = $application->find('wallabag:purge-entry-deletions'); + + $tester = new CommandTester($command); + $tester->setInputs(['yes']); // confirm deletion + $tester->execute([]); + + $this->assertStringContainsString('Successfully deleted 2 records', $tester->getDisplay()); + $this->assertSame(0, $tester->getStatusCode()); + + $count = $this->entryDeletionRepository->countAllBefore($this->expirationConfig->getCutoffDate()); + $this->assertSame(0, $count); + + $countAll = $this->entryDeletionRepository->countAllBefore(new \DateTime('now')); + $this->assertSame(1, $countAll); + } + + public function testRunPurgeEntryDeletionsCommandWithNoRecords() + { + $this->expirationConfig->setExpirationDays(10); + + $application = new Application(self::$kernel); + $command = $application->find('wallabag:purge-entry-deletions'); + + $tester = new CommandTester($command); + $tester->execute([]); + + $this->assertStringContainsString('No entry deletion records found', $tester->getDisplay()); + $this->assertSame(0, $tester->getStatusCode()); + } +} diff --git a/tests/Controller/Api/EntryDeletionRestControllerTest.php b/tests/Controller/Api/EntryDeletionRestControllerTest.php new file mode 100644 index 000000000..104cdf0e8 --- /dev/null +++ b/tests/Controller/Api/EntryDeletionRestControllerTest.php @@ -0,0 +1,62 @@ +client->request('GET', '/api/entry-deletions'); + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + // check that only the items for the current user are returned + $this->assertCount(2, $content['_embedded']['items']); + + // validate the deletion schema on the first item + $deletionData = $content['_embedded']['items'][0]; + $this->assertArrayHasKey('id', $deletionData); + $this->assertArrayHasKey('entry_id', $deletionData); + $this->assertArrayHasKey('deleted_at', $deletionData); + $this->assertArrayNotHasKey('user_id', $deletionData); + } + + public function testGetEntryDeletionsSince() + { + $since = (new \DateTime('-2 days'))->getTimestamp(); + $this->client->request('GET', "/api/entry-deletions?since={$since}"); + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + $this->assertGreaterThanOrEqual(1, \count($content['_embedded']['items'])); + } + + public function testGetEntryDeletionsWithSinceBeforeCutoffDate() + { + $sinceBeforeCutoff = (new \DateTime('-410 days'))->getTimestamp(); + $this->client->request('GET', "/api/entry-deletions?since={$sinceBeforeCutoff}"); + $this->assertSame(410, $this->client->getResponse()->getStatusCode()); + + $content = $this->client->getResponse()->getContent(); + $this->assertStringContainsString('The requested since date', $content); + $this->assertStringContainsString('is before the data retention cutoff date', $content); + $this->assertStringContainsString('X-Wallabag-Entry-Deletion-Cutoff', $content); + + $response = $this->client->getResponse(); + $this->assertTrue($response->headers->has('X-Wallabag-Entry-Deletion-Cutoff')); + + $cutoffTimestamp = $response->headers->get('X-Wallabag-Entry-Deletion-Cutoff'); + $this->assertIsNumeric($cutoffTimestamp); + $this->assertGreaterThan($sinceBeforeCutoff, (int) $cutoffTimestamp); + } +} diff --git a/tests/Event/Subscriber/EntryDeletionSubscriberTest.php b/tests/Event/Subscriber/EntryDeletionSubscriberTest.php new file mode 100644 index 000000000..eb0eb2037 --- /dev/null +++ b/tests/Event/Subscriber/EntryDeletionSubscriberTest.php @@ -0,0 +1,45 @@ +getProperty('id'); + $property->setAccessible(true); + $property->setValue($entry, 123); + + $em = $this->createMock(EntityManagerInterface::class); + + // when the event is triggered, an EntryDeletion should be persisted and flushed + $em->expects($this->once()) + ->method('persist') + ->with($this->callback(function ($deletion) use ($entry) { + return $deletion instanceof EntryDeletion + && $deletion->getEntryId() === $entry->getId() + && $deletion->getUser() === $entry->getUser(); + })); + $em->expects($this->atLeastOnce()) + ->method('flush'); + + // trigger the event to run the mocked up persist and flush + /** @var EntityManagerInterface $em */ + $subscriber = new EntryDeletionSubscriber($em); + $event = new EntryDeletedEvent($entry); + $subscriber->onEntryDeleted($event); + } +}