mirror of
https://github.com/wallabag/wallabag.git
synced 2025-08-06 17:41:01 +00:00
Merge c1a8772c21
into d4472a1684
This commit is contained in:
commit
521e1d1fda
20 changed files with 892 additions and 4 deletions
|
@ -31,6 +31,7 @@ services:
|
||||||
$supportUrl: '@=service(''craue_config'').get(''wallabag_support_url'')'
|
$supportUrl: '@=service(''craue_config'').get(''wallabag_support_url'')'
|
||||||
$fonts: '%wallabag.fonts%'
|
$fonts: '%wallabag.fonts%'
|
||||||
$defaultIgnoreOriginInstanceRules: '%wallabag.default_ignore_origin_instance_rules%'
|
$defaultIgnoreOriginInstanceRules: '%wallabag.default_ignore_origin_instance_rules%'
|
||||||
|
$entryDeletionExpirationDays: '%wallabag.entry_deletion_expiration_days%'
|
||||||
|
|
||||||
Wallabag\:
|
Wallabag\:
|
||||||
resource: '../../src/*'
|
resource: '../../src/*'
|
||||||
|
@ -254,6 +255,11 @@ services:
|
||||||
arguments:
|
arguments:
|
||||||
$baseFolder: "%kernel.project_dir%/web/assets/images"
|
$baseFolder: "%kernel.project_dir%/web/assets/images"
|
||||||
|
|
||||||
|
|
||||||
|
Wallabag\Helper\EntryDeletionExpirationConfig:
|
||||||
|
arguments:
|
||||||
|
$defaultExpirationDays: '%wallabag.entry_deletion_expiration_days%'
|
||||||
|
|
||||||
Wallabag\Command\InstallCommand:
|
Wallabag\Command\InstallCommand:
|
||||||
arguments:
|
arguments:
|
||||||
$databaseDriver: '%database_driver%'
|
$databaseDriver: '%database_driver%'
|
||||||
|
|
|
@ -32,6 +32,7 @@ parameters:
|
||||||
wallabag.action_mark_as_read: 1
|
wallabag.action_mark_as_read: 1
|
||||||
wallabag.list_mode: 0
|
wallabag.list_mode: 0
|
||||||
wallabag.display_thumbnails: 1
|
wallabag.display_thumbnails: 1
|
||||||
|
wallabag.entry_deletion_expiration_days: 90
|
||||||
wallabag.fetching_error_message_title: 'No title found'
|
wallabag.fetching_error_message_title: 'No title found'
|
||||||
wallabag.fetching_error_message: |
|
wallabag.fetching_error_message: |
|
||||||
wallabag can't retrieve contents for this article. Please <a href="https://doc.wallabag.org/en/user/errors_during_fetching.html#how-can-i-help-to-fix-that">troubleshoot this issue</a>.
|
wallabag can't retrieve contents for this article. Please <a href="https://doc.wallabag.org/en/user/errors_during_fetching.html#how-can-i-help-to-fix-that">troubleshoot this issue</a>.
|
||||||
|
|
|
@ -156,7 +156,8 @@
|
||||||
"wallabag/rulerz": "dev-master",
|
"wallabag/rulerz": "dev-master",
|
||||||
"wallabag/rulerz-bundle": "dev-master",
|
"wallabag/rulerz-bundle": "dev-master",
|
||||||
"willdurand/hateoas": "^3.12",
|
"willdurand/hateoas": "^3.12",
|
||||||
"willdurand/hateoas-bundle": "^2.7"
|
"willdurand/hateoas-bundle": "^2.7",
|
||||||
|
"zircote/swagger-php": "^4.11.1 || ^5.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"dama/doctrine-test-bundle": "^8.2.2",
|
"dama/doctrine-test-bundle": "^8.2.2",
|
||||||
|
|
6
composer.lock
generated
6
composer.lock
generated
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "00b8f95df6ec0572c06ad6f34847405c",
|
"content-hash": "b47338ba84f717112fd3f36632c65e03",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "babdev/pagerfanta-bundle",
|
"name": "babdev/pagerfanta-bundle",
|
||||||
|
@ -19449,6 +19449,6 @@
|
||||||
"ext-tokenizer": "*",
|
"ext-tokenizer": "*",
|
||||||
"ext-xml": "*"
|
"ext-xml": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|
53
fixtures/EntryDeletionFixtures.php
Normal file
53
fixtures/EntryDeletionFixtures.php
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\DataFixtures;
|
||||||
|
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
class EntryDeletionFixtures extends Fixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$adminUser = $this->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
37
migrations/Version20250530183653.php
Normal file
37
migrations/Version20250530183653.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Application\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Wallabag\Doctrine\WallabagMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the entry deletion table to keep a history of deleted entries.
|
||||||
|
*/
|
||||||
|
final class Version20250530183653 extends WallabagMigration
|
||||||
|
{
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
77
src/Command/PurgeEntryDeletionsCommand.php
Normal file
77
src/Command/PurgeEntryDeletionsCommand.php
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Wallabag\Helper\EntryDeletionExpirationConfig;
|
||||||
|
use Wallabag\Repository\EntryDeletionRepository;
|
||||||
|
|
||||||
|
class PurgeEntryDeletionsCommand extends Command
|
||||||
|
{
|
||||||
|
protected static $defaultName = 'wallabag:purge-entry-deletions';
|
||||||
|
protected static $defaultDescription = 'Purge old entry deletion records';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntryDeletionRepository $entryDeletionRepository,
|
||||||
|
private readonly EntryDeletionExpirationConfig $expirationConfig,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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 <info>enabled</info> (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;
|
||||||
|
}
|
||||||
|
}
|
118
src/Controller/Api/EntryDeletionRestController.php
Normal file
118
src/Controller/Api/EntryDeletionRestController.php
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Hateoas\Configuration\Route as HateoasRoute;
|
||||||
|
use Hateoas\Representation\Factory\PagerfantaFactory;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Helper\EntryDeletionExpirationConfig;
|
||||||
|
use Wallabag\OpenApi\Attribute as WOA;
|
||||||
|
use Wallabag\Repository\EntryDeletionRepository;
|
||||||
|
|
||||||
|
class EntryDeletionRestController extends WallabagRestController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retrieve all entry deletions for the current user.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/api/entry-deletions.{_format}', name: 'api_get_entry_deletions', methods: ['GET'], defaults: ['_format' => '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);
|
||||||
|
}
|
||||||
|
}
|
107
src/Entity/EntryDeletion.php
Normal file
107
src/Entity/EntryDeletion.php
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use JMS\Serializer\Annotation as Serializer;
|
||||||
|
use Wallabag\Repository\EntryDeletionRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EntryDeletion.
|
||||||
|
*
|
||||||
|
* Tracks when entries are deleted for client synchronization purposes.
|
||||||
|
*/
|
||||||
|
#[ORM\Table(name: '`entry_deletion`')]
|
||||||
|
#[ORM\Index(columns: ['deleted_at'])]
|
||||||
|
#[ORM\Index(columns: ['user_id', 'deleted_at'])]
|
||||||
|
#[ORM\Entity(repositoryClass: EntryDeletionRepository::class)]
|
||||||
|
class EntryDeletion
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'id', type: 'integer')]
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue(strategy: 'AUTO')]
|
||||||
|
private $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'entry_id', type: 'integer')]
|
||||||
|
private $entryId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \DateTimeInterface
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime')]
|
||||||
|
private $deletedAt;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[Serializer\Exclude()]
|
||||||
|
private $user;
|
||||||
|
|
||||||
|
public function __construct(User $user, int $entryId)
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
36
src/Event/Subscriber/EntryDeletionSubscriber.php
Normal file
36
src/Event/Subscriber/EntryDeletionSubscriber.php
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Event\Subscriber;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Event\EntryDeletedEvent;
|
||||||
|
|
||||||
|
class EntryDeletionSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EntryDeletedEvent::NAME => '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();
|
||||||
|
}
|
||||||
|
}
|
34
src/Helper/EntryDeletionExpirationConfig.php
Normal file
34
src/Helper/EntryDeletionExpirationConfig.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Helper;
|
||||||
|
|
||||||
|
class EntryDeletionExpirationConfig
|
||||||
|
{
|
||||||
|
private int $expirationDays;
|
||||||
|
|
||||||
|
public function __construct(int $defaultExpirationDays)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
22
src/OpenApi/Attribute/OrderParameter.php
Normal file
22
src/OpenApi/Attribute/OrderParameter.php
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\OpenApi\Attribute;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD)]
|
||||||
|
class OrderParameter extends OA\Parameter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $defaultName = 'order',
|
||||||
|
public readonly string $default = 'desc',
|
||||||
|
) {
|
||||||
|
parent::__construct(
|
||||||
|
name: $defaultName,
|
||||||
|
in: 'query',
|
||||||
|
description: 'Order of results (asc or desc).',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(type: 'string', enum: ['asc', 'desc'], default: $default)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
59
src/OpenApi/Attribute/PagerFanta/JsonContent.php
Normal file
59
src/OpenApi/Attribute/PagerFanta/JsonContent.php
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\OpenApi\Attribute\PagerFanta;
|
||||||
|
|
||||||
|
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||||
|
class JsonContent extends OA\JsonContent
|
||||||
|
{
|
||||||
|
public function __construct(string|array|null $modelClass = null)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(
|
||||||
|
property: '_embedded',
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(
|
||||||
|
property: 'items',
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(ref: new Model(type: $modelClass))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
new OA\Property(property: 'page', type: 'integer'),
|
||||||
|
new OA\Property(property: 'limit', type: 'integer'),
|
||||||
|
new OA\Property(property: 'pages', type: 'integer'),
|
||||||
|
new OA\Property(property: 'total', type: 'integer'),
|
||||||
|
new OA\Property(
|
||||||
|
property: '_links',
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(
|
||||||
|
property: 'self',
|
||||||
|
type: 'object',
|
||||||
|
properties: [new OA\Property(property: 'href', type: 'string')]
|
||||||
|
),
|
||||||
|
new OA\Property(
|
||||||
|
property: 'first',
|
||||||
|
type: 'object',
|
||||||
|
properties: [new OA\Property(property: 'href', type: 'string')]
|
||||||
|
),
|
||||||
|
new OA\Property(
|
||||||
|
property: 'last',
|
||||||
|
type: 'object',
|
||||||
|
properties: [new OA\Property(property: 'href', type: 'string')]
|
||||||
|
),
|
||||||
|
new OA\Property(
|
||||||
|
property: 'next',
|
||||||
|
type: 'object',
|
||||||
|
properties: [new OA\Property(property: 'href', type: 'string')]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
22
src/OpenApi/Attribute/PagerFanta/PageParameter.php
Normal file
22
src/OpenApi/Attribute/PagerFanta/PageParameter.php
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\OpenApi\Attribute\PagerFanta;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD)]
|
||||||
|
class PageParameter extends OA\Parameter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $defaultName = 'page',
|
||||||
|
public readonly int $default = 1,
|
||||||
|
) {
|
||||||
|
parent::__construct(
|
||||||
|
name: $defaultName,
|
||||||
|
in: 'query',
|
||||||
|
description: 'Requested page number.',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(type: 'integer', default: $default)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
22
src/OpenApi/Attribute/PagerFanta/PerPageParameter.php
Normal file
22
src/OpenApi/Attribute/PagerFanta/PerPageParameter.php
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\OpenApi\Attribute\PagerFanta;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD)]
|
||||||
|
class PerPageParameter extends OA\Parameter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $defaultName = 'perPage',
|
||||||
|
public readonly int $default = 30,
|
||||||
|
) {
|
||||||
|
parent::__construct(
|
||||||
|
name: $defaultName,
|
||||||
|
in: 'query',
|
||||||
|
description: 'Number of items per page.',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(type: 'integer', default: $default)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
57
src/Repository/EntryDeletionRepository.php
Normal file
57
src/Repository/EntryDeletionRepository.php
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Repository;
|
||||||
|
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Pagerfanta\Doctrine\ORM\QueryAdapter as DoctrineORMAdapter;
|
||||||
|
use Pagerfanta\Pagerfanta;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
|
||||||
|
class EntryDeletionRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EntryDeletion::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find deletions for a specific user since a given date. The result is paginated.
|
||||||
|
*/
|
||||||
|
public function findEntryDeletions(int $userId, int $since = 0, string $order = 'asc'): Pagerfanta
|
||||||
|
{
|
||||||
|
$qb = $this->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();
|
||||||
|
}
|
||||||
|
}
|
43
src/Security/Voter/EntryDeletionVoter.php
Normal file
43
src/Security/Voter/EntryDeletionVoter.php
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Security\Voter;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
class EntryDeletionVoter extends Voter
|
||||||
|
{
|
||||||
|
public const VIEW = 'VIEW';
|
||||||
|
public const LIST = 'LIST';
|
||||||
|
|
||||||
|
protected function supports(string $attribute, $subject): bool
|
||||||
|
{
|
||||||
|
if (!$subject instanceof EntryDeletion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\in_array($attribute, [self::VIEW, self::LIST], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||||
|
{
|
||||||
|
\assert($subject instanceof EntryDeletion);
|
||||||
|
|
||||||
|
$user = $token->getUser();
|
||||||
|
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($attribute) {
|
||||||
|
self::VIEW, self::LIST => $user === $subject->getUser(),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
86
tests/Command/PurgeEntryDeletionsCommandTest.php
Normal file
86
tests/Command/PurgeEntryDeletionsCommandTest.php
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Command;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Helper\EntryDeletionExpirationConfig;
|
||||||
|
use Wallabag\Repository\EntryDeletionRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the purge entry deletions command.
|
||||||
|
*
|
||||||
|
* The fixtures set up the following entry deletions:
|
||||||
|
* - Admin user: 1 deletion from 4 days ago (entry_id: 1004)
|
||||||
|
* - Admin user: 1 deletion from 1 day ago (entry_id: 1001)
|
||||||
|
* - Bob user: 1 deletion from 3 days ago (entry_id: 1003)
|
||||||
|
*/
|
||||||
|
class PurgeEntryDeletionsCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntryDeletionExpirationConfig $expirationConfig;
|
||||||
|
private EntryDeletionRepository $entryDeletionRepository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
62
tests/Controller/Api/EntryDeletionRestControllerTest.php
Normal file
62
tests/Controller/Api/EntryDeletionRestControllerTest.php
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the entry deletion REST API endpoints.
|
||||||
|
*
|
||||||
|
* The fixtures set up the following entry deletions:
|
||||||
|
* - Admin user: 1 deletion from 4 days ago (entry_id: 1004)
|
||||||
|
* - Admin user: 1 deletion from 1 day ago (entry_id: 1001)
|
||||||
|
* - Bob user: 1 deletion from 3 days ago (entry_id: 1003)
|
||||||
|
*
|
||||||
|
* The logged in user is admin.
|
||||||
|
*/
|
||||||
|
class EntryDeletionRestControllerTest extends WallabagApiTestCase
|
||||||
|
{
|
||||||
|
public function testGetEntryDeletions()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
45
tests/Event/Subscriber/EntryDeletionSubscriberTest.php
Normal file
45
tests/Event/Subscriber/EntryDeletionSubscriberTest.php
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Event\Subscriber;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Wallabag\Entity\Entry;
|
||||||
|
use Wallabag\Entity\EntryDeletion;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
use Wallabag\Event\EntryDeletedEvent;
|
||||||
|
use Wallabag\Event\Subscriber\EntryDeletionSubscriber;
|
||||||
|
|
||||||
|
class EntryDeletionSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEntryDeletionCreatedWhenEntryDeleted(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$entry = new Entry($user);
|
||||||
|
|
||||||
|
// the subscriber expects a previously persisted Entry to work
|
||||||
|
$reflectedEntry = new \ReflectionClass($entry);
|
||||||
|
$property = $reflectedEntry->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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue