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);
+ }
+}