From 00b6d3e922e9dfa8142e56d4800d11917d379136 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 25 Feb 2026 12:27:47 +0100 Subject: [PATCH 1/2] Add modlogs for purges by moderators - when a moderator purges content this should also be reflected in the modlog -> add new modlog types with a title property, as the entity is purged, so no longer referencable --- src/Controller/ModlogController.php | 2 +- src/DTO/MagazineLogResponseDto.php | 17 +++- src/Entity/MagazineLog.php | 12 ++- src/Entity/MagazineLogEntryCommentPurged.php | 44 +++++++++ src/Entity/MagazineLogEntryPurged.php | 44 +++++++++ src/Entity/MagazineLogPostCommentPurged.php | 44 +++++++++ src/Entity/MagazineLogPostPurged.php | 44 +++++++++ .../Magazine/MagazineLogSubscriber.php | 71 +++++++++++++- src/Factory/MagazineFactory.php | 12 +++ src/Form/ModlogFilterType.php | 4 + templates/modlog/_blocks.html.twig | 20 ++++ .../MagazinePurgeContentModLogApiTest.php | 95 +++++++++++++++++++ tests/ValidationTrait.php | 7 ++ tests/WebTestCase.php | 2 +- translations/messages.en.yaml | 8 ++ 15 files changed, 417 insertions(+), 9 deletions(-) create mode 100644 src/Entity/MagazineLogEntryCommentPurged.php create mode 100644 src/Entity/MagazineLogEntryPurged.php create mode 100644 src/Entity/MagazineLogPostCommentPurged.php create mode 100644 src/Entity/MagazineLogPostPurged.php create mode 100644 tests/Functional/Controller/Api/Magazine/MagazinePurgeContentModLogApiTest.php diff --git a/src/Controller/ModlogController.php b/src/Controller/ModlogController.php index bcfdfe1122..e96f191d35 100644 --- a/src/Controller/ModlogController.php +++ b/src/Controller/ModlogController.php @@ -23,7 +23,7 @@ public function instance(Request $request): Response { $dto = new ModlogFilterDto(); $dto->magazine = null; - $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET']); + $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET', 'csrf_protection' => false]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { /** @var ModlogFilterDto $dto */ diff --git a/src/DTO/MagazineLogResponseDto.php b/src/DTO/MagazineLogResponseDto.php index 1b84f7bf62..e8fb923bed 100644 --- a/src/DTO/MagazineLogResponseDto.php +++ b/src/DTO/MagazineLogResponseDto.php @@ -20,15 +20,19 @@ #[OA\Schema()] class MagazineLogResponseDto implements \JsonSerializable { - public const LOG_TYPES = [ + public const array LOG_TYPES = [ 'log_entry_deleted', 'log_entry_restored', + 'log_entry_purged', 'log_entry_comment_deleted', 'log_entry_comment_restored', + 'log_entry_comment_purged', 'log_post_deleted', 'log_post_restored', + 'log_post_purged', 'log_post_comment_deleted', 'log_post_comment_restored', + 'log_post_comment_purged', 'log_ban', 'log_unban', 'log_entry_pinned', @@ -48,11 +52,17 @@ class MagazineLogResponseDto implements \JsonSerializable * - PostCommentResponseDto when type is 'log_post_comment_deleted' or 'log_post_comment_restored' * - MagazineBanResponseDto when type is 'log_ban' or 'log_unban' * - UserSmallResponseDto when type is 'log_moderator_add' or 'log_moderator_remove' + * - string when type is 'log_entry_purged', 'log_entry_comment_purged', 'log_post_purged' or 'log_post_comment_purged' */ #[OA\Property('subject')] // If this property is named 'subject' the api doc generator will not pick it up. // It is still serialized as 'subject', see the jsonSerialize method. - public EntryResponseDto|EntryCommentResponseDto|PostResponseDto|PostCommentResponseDto|MagazineBanResponseDto|UserSmallResponseDto|null $subject2 = null; + public EntryResponseDto|EntryCommentResponseDto|PostResponseDto|PostCommentResponseDto|MagazineBanResponseDto|UserSmallResponseDto|string|null $subject2 = null; + + /** + * Only set if the subject is a string, otherwise the author information is contained within the subject. + */ + public ?UserSmallResponseDto $subjectAuthor = null; public static function create( MagazineSmallResponseDto $magazine, @@ -139,7 +149,8 @@ public function jsonSerialize(): mixed 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), 'magazine' => $this->magazine, 'moderator' => $this->moderator, - 'subject' => $this->subject2?->jsonSerialize(), + 'subject' => \is_string($this->subject2) ? $this->subject2 : $this->subject2?->jsonSerialize(), + 'subjectAuthor' => $this->subjectAuthor?->jsonSerialize(), ]; } } diff --git a/src/Entity/MagazineLog.php b/src/Entity/MagazineLog.php index a6d88c1b92..2f9a5b2850 100644 --- a/src/Entity/MagazineLog.php +++ b/src/Entity/MagazineLog.php @@ -27,17 +27,21 @@ abstract class MagazineLog CreatedAtTrait::__construct as createdAtTraitConstruct; } - public const DISCRIMINATOR_MAP = [ + public const array DISCRIMINATOR_MAP = [ 'entry_deleted' => MagazineLogEntryDeleted::class, 'entry_restored' => MagazineLogEntryRestored::class, + 'entry_purged' => MagazineLogEntryPurged::class, 'entry_comment_deleted' => MagazineLogEntryCommentDeleted::class, 'entry_comment_restored' => MagazineLogEntryCommentRestored::class, + 'entry_comment_purged' => MagazineLogEntryCommentPurged::class, 'entry_pinned' => MagazineLogEntryPinned::class, 'entry_unpinned' => MagazineLogEntryUnpinned::class, 'post_deleted' => MagazineLogPostDeleted::class, 'post_restored' => MagazineLogPostRestored::class, + 'post_purged' => MagazineLogPostPurged::class, 'post_comment_deleted' => MagazineLogPostCommentDeleted::class, 'post_comment_restored' => MagazineLogPostCommentRestored::class, + 'post_comment_purged' => MagazineLogPostCommentPurged::class, 'ban' => MagazineLogBan::class, 'moderator_add' => MagazineLogModeratorAdd::class, 'moderator_remove' => MagazineLogModeratorRemove::class, @@ -47,17 +51,21 @@ abstract class MagazineLog 'post_unlocked' => MagazineLogPostUnlocked::class, ]; - public const CHOICES = [ + public const array CHOICES = [ 'entry_deleted', 'entry_restored', + 'entry_purged', 'entry_comment_deleted', 'entry_comment_restored', + 'entry_comment_purged', 'entry_pinned', 'entry_unpinned', 'post_deleted', 'post_restored', + 'post_purged', 'post_comment_deleted', 'post_comment_restored', + 'post_comment_purged', 'ban', 'moderator_add', 'moderator_remove', diff --git a/src/Entity/MagazineLogEntryCommentPurged.php b/src/Entity/MagazineLogEntryCommentPurged.php new file mode 100644 index 0000000000..bb6581d887 --- /dev/null +++ b/src/Entity/MagazineLogEntryCommentPurged.php @@ -0,0 +1,44 @@ +title = $title; + $this->author = $author; + } + + public function getSubject(): ?ContentInterface + { + return null; + } + + public function clearSubject(): MagazineLog + { + return $this; + } + + public function getType(): string + { + return 'log_entry_comment_purged'; + } +} diff --git a/src/Entity/MagazineLogEntryPurged.php b/src/Entity/MagazineLogEntryPurged.php new file mode 100644 index 0000000000..1d9eef8266 --- /dev/null +++ b/src/Entity/MagazineLogEntryPurged.php @@ -0,0 +1,44 @@ +title = $title; + $this->author = $author; + } + + public function getSubject(): ?ContentInterface + { + return null; + } + + public function clearSubject(): MagazineLog + { + return $this; + } + + public function getType(): string + { + return 'log_entry_purged'; + } +} diff --git a/src/Entity/MagazineLogPostCommentPurged.php b/src/Entity/MagazineLogPostCommentPurged.php new file mode 100644 index 0000000000..bcad362dd1 --- /dev/null +++ b/src/Entity/MagazineLogPostCommentPurged.php @@ -0,0 +1,44 @@ +title = $title; + $this->author = $author; + } + + public function getSubject(): ?ContentInterface + { + return null; + } + + public function clearSubject(): MagazineLog + { + return $this; + } + + public function getType(): string + { + return 'log_post_comment_purged'; + } +} diff --git a/src/Entity/MagazineLogPostPurged.php b/src/Entity/MagazineLogPostPurged.php new file mode 100644 index 0000000000..6b1dfd5559 --- /dev/null +++ b/src/Entity/MagazineLogPostPurged.php @@ -0,0 +1,44 @@ +title = $title; + $this->author = $author; + } + + public function getSubject(): ?ContentInterface + { + return null; + } + + public function clearSubject(): MagazineLog + { + return $this; + } + + public function getType(): string + { + return 'log_post_purged'; + } +} diff --git a/src/EventSubscriber/Magazine/MagazineLogSubscriber.php b/src/EventSubscriber/Magazine/MagazineLogSubscriber.php index 44fc489f57..5609728f18 100644 --- a/src/EventSubscriber/Magazine/MagazineLogSubscriber.php +++ b/src/EventSubscriber/Magazine/MagazineLogSubscriber.php @@ -6,29 +6,40 @@ use App\Entity\MagazineLogBan; use App\Entity\MagazineLogEntryCommentDeleted; +use App\Entity\MagazineLogEntryCommentPurged; use App\Entity\MagazineLogEntryCommentRestored; use App\Entity\MagazineLogEntryDeleted; +use App\Entity\MagazineLogEntryPurged; use App\Entity\MagazineLogEntryRestored; use App\Entity\MagazineLogPostCommentDeleted; +use App\Entity\MagazineLogPostCommentPurged; use App\Entity\MagazineLogPostCommentRestored; use App\Entity\MagazineLogPostDeleted; +use App\Entity\MagazineLogPostPurged; use App\Entity\MagazineLogPostRestored; +use App\Event\Entry\EntryBeforePurgeEvent; use App\Event\Entry\EntryDeletedEvent; use App\Event\Entry\EntryRestoredEvent; +use App\Event\EntryComment\EntryCommentBeforePurgeEvent; use App\Event\EntryComment\EntryCommentDeletedEvent; use App\Event\EntryComment\EntryCommentRestoredEvent; use App\Event\Magazine\MagazineBanEvent; +use App\Event\Post\PostBeforePurgeEvent; use App\Event\Post\PostDeletedEvent; use App\Event\Post\PostRestoredEvent; +use App\Event\PostComment\PostCommentBeforePurgeEvent; use App\Event\PostComment\PostCommentDeletedEvent; use App\Event\PostComment\PostCommentRestoredEvent; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class MagazineLogSubscriber implements EventSubscriberInterface { - public function __construct(private readonly EntityManagerInterface $entityManager) - { + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + ) { } public static function getSubscribedEvents(): array @@ -36,12 +47,16 @@ public static function getSubscribedEvents(): array return [ EntryDeletedEvent::class => 'onEntryDeleted', EntryRestoredEvent::class => 'onEntryRestored', + EntryBeforePurgeEvent::class => 'onEntryPurged', EntryCommentDeletedEvent::class => 'onEntryCommentDeleted', EntryCommentRestoredEvent::class => 'onEntryCommentRestored', + EntryCommentBeforePurgeEvent::class => 'onEntryCommentPurged', PostDeletedEvent::class => 'onPostDeleted', PostRestoredEvent::class => 'onPostRestored', + PostBeforePurgeEvent::class => 'onPostPurged', PostCommentDeletedEvent::class => 'onPostCommentDeleted', PostCommentRestoredEvent::class => 'onPostCommentRestored', + PostCommentBeforePurgeEvent::class => 'onPostCommentPurged', MagazineBanEvent::class => 'onBan', ]; } @@ -78,6 +93,19 @@ public function onEntryRestored(EntryRestoredEvent $event): void $this->entityManager->flush(); } + public function onEntryPurged(EntryBeforePurgeEvent $event): void + { + if (!$event->user || $event->entry->isAuthor($event->user)) { + return; + } + + $log = new MagazineLogEntryPurged($event->entry->magazine, $event->user, $event->entry->title, $event->entry->user); + $this->logger->info('Entry "{t}" from {u} was purged by mod {u2}', ['t' => $log->title, 'u' => $log->author->username, 'u2' => $log->user->username]); + + $this->entityManager->persist($log); + $this->entityManager->flush(); + } + public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void { if (!$event->comment->isTrashed()) { @@ -110,6 +138,19 @@ public function onEntryCommentRestored(EntryCommentRestoredEvent $event): void $this->entityManager->flush(); } + public function onEntryCommentPurged(EntryCommentBeforePurgeEvent $event): void + { + if (!$event->user || $event->comment->isAuthor($event->user)) { + return; + } + + $log = new MagazineLogEntryCommentPurged($event->comment->magazine, $event->user, $event->comment->getShortTitle(), $event->comment->user); + $this->logger->info('Entry comment "{t}" from {u} was purged by mod {u2}', ['t' => $log->title, 'u' => $log->author->username, 'u2' => $log->user->username]); + + $this->entityManager->persist($log); + $this->entityManager->flush(); + } + public function onPostDeleted(PostDeletedEvent $event): void { if (!$event->post->isTrashed()) { @@ -142,6 +183,19 @@ public function onPostRestored(PostRestoredEvent $event): void $this->entityManager->flush(); } + public function onPostPurged(PostBeforePurgeEvent $event): void + { + if (!$event->user || $event->post->isAuthor($event->user)) { + return; + } + + $log = new MagazineLogPostPurged($event->post->magazine, $event->user, $event->post->getShortTitle(), $event->post->user); + $this->logger->info('Post "{t}" from {u} was purged by mod {u2}', ['t' => $log->title, 'u' => $log->author->username, 'u2' => $log->user->username]); + + $this->entityManager->persist($log); + $this->entityManager->flush(); + } + public function onPostCommentDeleted(PostCommentDeletedEvent $event): void { if (!$event->comment->isTrashed()) { @@ -174,6 +228,19 @@ public function onPostCommentRestored(PostCommentRestoredEvent $event): void $this->entityManager->flush(); } + public function onPostCommentPurged(PostCommentBeforePurgeEvent $event): void + { + if (!$event->user || $event->comment->isAuthor($event->user)) { + return; + } + + $log = new MagazineLogPostCommentPurged($event->comment->magazine, $event->user, $event->comment->getShortTitle(), $event->comment->user); + $this->logger->info('Post comment "{t}" from {u} was purged by mod {u2}', ['t' => $log->title, 'u' => $log->author->username, 'u2' => $log->user->username]); + + $this->entityManager->persist($log); + $this->entityManager->flush(); + } + public function onBan(MagazineBanEvent $event): void { $log = new MagazineLogBan($event->ban); diff --git a/src/Factory/MagazineFactory.php b/src/Factory/MagazineFactory.php index dc4e9b3f8b..5c2558757a 100644 --- a/src/Factory/MagazineFactory.php +++ b/src/Factory/MagazineFactory.php @@ -16,8 +16,12 @@ use App\Entity\MagazineBan; use App\Entity\MagazineLog; use App\Entity\MagazineLogBan; +use App\Entity\MagazineLogEntryCommentPurged; +use App\Entity\MagazineLogEntryPurged; use App\Entity\MagazineLogModeratorAdd; use App\Entity\MagazineLogModeratorRemove; +use App\Entity\MagazineLogPostCommentPurged; +use App\Entity\MagazineLogPostPurged; use App\Entity\Moderator; use App\Entity\User; use App\Repository\InstanceRepository; @@ -136,6 +140,14 @@ public function createLogDto(MagazineLog $log): MagazineLogResponseDto } return MagazineLogResponseDto::createBanUnban($magazine, $moderator, $createdAt, $type, $banSubject); + } elseif ($log instanceof MagazineLogEntryPurged || $log instanceof MagazineLogEntryCommentPurged || $log instanceof MagazineLogPostPurged || $log instanceof MagazineLogPostCommentPurged) { + $moderator = $this->userFactory->createSmallDto($log->user); + $author = $this->userFactory->createSmallDto($log->author); + $dto = MagazineLogResponseDto::create($magazine, $moderator, $createdAt, $type); + $dto->subject2 = $log->title; + $dto->subjectAuthor = $author; + + return $dto; } else { $moderator = $this->userFactory->createSmallDto($log->user); diff --git a/src/Form/ModlogFilterType.php b/src/Form/ModlogFilterType.php index 9c35121d80..f8df86ffa9 100644 --- a/src/Form/ModlogFilterType.php +++ b/src/Form/ModlogFilterType.php @@ -26,14 +26,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => [ $this->translator->trans('modlog_type_entry_deleted') => 'entry_deleted', $this->translator->trans('modlog_type_entry_restored') => 'entry_restored', + $this->translator->trans('modlog_type_entry_purged') => 'entry_purged', $this->translator->trans('modlog_type_entry_comment_deleted') => 'entry_comment_deleted', $this->translator->trans('modlog_type_entry_comment_restored') => 'entry_comment_restored', + $this->translator->trans('modlog_type_entry_comment_purged') => 'entry_comment_purged', $this->translator->trans('modlog_type_entry_pinned') => 'entry_pinned', $this->translator->trans('modlog_type_entry_unpinned') => 'entry_unpinned', $this->translator->trans('modlog_type_post_deleted') => 'post_deleted', $this->translator->trans('modlog_type_post_restored') => 'post_restored', + $this->translator->trans('modlog_type_post_purged') => 'post_purged', $this->translator->trans('modlog_type_post_comment_deleted') => 'post_comment_deleted', $this->translator->trans('modlog_type_post_comment_restored') => 'post_comment_restored', + $this->translator->trans('modlog_type_post_comment_purged') => 'post_comment_purged', $this->translator->trans('modlog_type_ban') => 'ban', $this->translator->trans('modlog_type_moderator_add') => 'moderator_add', $this->translator->trans('modlog_type_moderator_remove') => 'moderator_remove', diff --git a/templates/modlog/_blocks.html.twig b/templates/modlog/_blocks.html.twig index 154742645b..56ea168e23 100644 --- a/templates/modlog/_blocks.html.twig +++ b/templates/modlog/_blocks.html.twig @@ -8,6 +8,11 @@ {{ log.entry.shortTitle(300) }} {% endblock %} +{% block log_entry_purged %} + {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'purged_thread_by'|trans|lower }} {{ component('user_inline', {user: log.author, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - + {{ 'former_title'|trans }}: {{ log.title }} +{% endblock %} + {% block log_entry_comment_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} @@ -18,6 +23,11 @@ {{ log.comment.shortTitle(300) }} {% endblock %} +{% block log_entry_comment_purged %} + {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'purged_comment_by'|trans|lower }} {{ component('user_inline', {user: log.author, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - + {{ 'former_title'|trans }}: {{ log.title }} +{% endblock %} + {% block log_post_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_post_by'|trans|lower }} {{ component('user_inline', {user: log.post.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.post.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.post.shortTitle(300) }} @@ -28,6 +38,11 @@ {{ log.post.shortTitle(300) }} {% endblock %} +{% block log_post_purged %} + {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'purged_post_by'|trans|lower }} {{ component('user_inline', {user: log.author, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - + {{ 'former_title'|trans }}: {{ log.title }} +{% endblock %} + {% block log_post_comment_deleted %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - {{ log.comment.shortTitle(300) }} @@ -38,6 +53,11 @@ {{ log.comment.shortTitle(300) }} {% endblock %} +{% block log_post_comment_purged %} + {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'purged_comment_by'|trans|lower }} {{ component('user_inline', {user: log.author, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} - + {{ 'former_title'|trans }}: {{ log.title }} +{% endblock %} + {% block log_ban %} {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {% if log.meta is same as 'ban' %} diff --git a/tests/Functional/Controller/Api/Magazine/MagazinePurgeContentModLogApiTest.php b/tests/Functional/Controller/Api/Magazine/MagazinePurgeContentModLogApiTest.php new file mode 100644 index 0000000000..d3dee50859 --- /dev/null +++ b/tests/Functional/Controller/Api/Magazine/MagazinePurgeContentModLogApiTest.php @@ -0,0 +1,95 @@ +getEntryByTitle('entry'); + $admin = $this->getUserByUsername('admin', isAdmin: true); + $this->entryManager->purge($admin, $entry); + + $this->client->request('GET', "/api/magazine/{$entry->magazine->getId()}/log"); + + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + self::assertIsArray($jsonData['items']); + self::assertCount(1, $jsonData['items']); + $this->validateModlog($jsonData, $entry->magazine, $admin); + self::assertEquals($entry->title, $jsonData['items'][0]['subject']); + } + + public function testPurgeEntryCommentModLog(): void + { + $entry = $this->getEntryByTitle('entry'); + $entryComment = $this->createEntryComment('entryComment', entry: $entry); + $admin = $this->getUserByUsername('admin', isAdmin: true); + + // otherwise we get persisting problems + $this->entityManager->remove($this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $entryComment)); + $this->entryCommentManager->purge($admin, $entryComment); + + $this->client->request('GET', "/api/magazine/{$entry->magazine->getId()}/log"); + + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + self::assertIsArray($jsonData['items']); + self::assertCount(1, $jsonData['items']); + $this->validateModlog($jsonData, $entry->magazine, $admin); + self::assertEquals($entryComment->getShortTitle(), $jsonData['items'][0]['subject']); + } + + public function testPurgePostModLog(): void + { + $post = $this->createPost('post'); + $admin = $this->getUserByUsername('admin', isAdmin: true); + $this->postManager->purge($admin, $post); + + $this->client->request('GET', "/api/magazine/{$post->magazine->getId()}/log"); + + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + self::assertIsArray($jsonData['items']); + self::assertCount(1, $jsonData['items']); + $this->validateModlog($jsonData, $post->magazine, $admin); + self::assertEquals($post->getShortTitle(), $jsonData['items'][0]['subject']); + } + + public function testPurgePostCommentModLog(): void + { + $post = $this->createPost('post'); + $postComment = $this->createPostComment('postComment', post: $post); + $admin = $this->getUserByUsername('admin', isAdmin: true); + + // otherwise we get persisting problems + $this->entityManager->remove($this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $postComment)); + + $this->postCommentManager->purge($admin, $postComment); + + $this->client->request('GET', "/api/magazine/{$post->magazine->getId()}/log"); + + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + self::assertIsArray($jsonData['items']); + self::assertCount(1, $jsonData['items']); + $this->validateModlog($jsonData, $post->magazine, $admin); + self::assertEquals($postComment->getShortTitle(), $jsonData['items'][0]['subject']); + } +} diff --git a/tests/ValidationTrait.php b/tests/ValidationTrait.php index 0079b66d88..e682e0d2d3 100644 --- a/tests/ValidationTrait.php +++ b/tests/ValidationTrait.php @@ -42,6 +42,13 @@ public function validateModlog(array $jsonData, Magazine $magazine, User $modera case 'log_unban': self::assertArrayKeysMatch(WebTestCase::BAN_RESPONSE_KEYS, $item['subject']); break; + case 'log_entry_purged': + case 'log_entry_comment_purged': + case 'log_post_purged': + case 'log_post_comment_purged': + self::assertTrue(\is_string($item['subject'])); + self::assertNotNull($item['subjectAuthor']); + break; default: self::assertTrue(false, 'This should not be reached'); break; diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 5e07478875..9b8fbc4c08 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -93,7 +93,7 @@ abstract class WebTestCase extends BaseWebTestCase protected const POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'isLocked', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'isAuthorModeratorInMagazine']; protected const POST_COMMENT_RESPONSE_KEYS = ['commentId', 'user', 'magazine', 'postId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine']; protected const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine']; - protected const LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject']; + protected const LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject', 'subjectAuthor']; protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index d0c3c69ba9..1f9b1f6ae8 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1143,3 +1143,11 @@ magazine_name_as_tag_help: The tags of a magazine are used to match microblog po containing "#fediverse" will be put in this magazine. magazine_rules_deprecated: the rules field is deprecated and will be removed in the future. Please put your rules in the description box. +purged_thread_by: purged thread by +purged_comment_by: purged comment by +purged_post_by: purged microblog by +former_title: Former title +modlog_type_entry_purged: Thread purged +modlog_type_entry_comment_purged: Thread comment purged +modlog_type_post_purged: Microblog purged +modlog_type_post_comment_purged: Microblog reply purged From bf632acabbef1a8977262ec267dd33b93bb6d0b7 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 25 Feb 2026 12:37:05 +0100 Subject: [PATCH 2/2] Add migration --- migrations/Version20260225095315.php | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 migrations/Version20260225095315.php diff --git a/migrations/Version20260225095315.php b/migrations/Version20260225095315.php new file mode 100644 index 0000000000..a2ed69623e --- /dev/null +++ b/migrations/Version20260225095315.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE magazine_log ADD author_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE magazine_log ADD title VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5F675F31B FOREIGN KEY (author_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_87D3D4C5F675F31B ON magazine_log (author_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5F675F31B'); + $this->addSql('DROP INDEX IDX_87D3D4C5F675F31B'); + $this->addSql('ALTER TABLE magazine_log DROP author_id'); + $this->addSql('ALTER TABLE magazine_log DROP title'); + $this->addSql('ALTER TABLE magazine_log DROP short_body'); + } +}