mirror of
https://github.com/wallabag/wallabag.git
synced 2025-06-27 16:36:00 +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'')'
|
||||
$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:
|
||||
|
|
|
@ -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 <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-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",
|
||||
|
|
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",
|
||||
"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"
|
||||
}
|
||||
|
|
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