diff --git a/config/kbin_routes/activity_pub.yaml b/config/kbin_routes/activity_pub.yaml index 3f579869c..e56f56f15 100644 --- a/config/kbin_routes/activity_pub.yaml +++ b/config/kbin_routes/activity_pub.yaml @@ -111,6 +111,12 @@ ap_magazine_moderators: methods: [GET] condition: '%kbin_ap_route_condition%' +ap_magazine_pinned: + controller: App\Controller\ActivityPub\Magazine\MagazinePinnedController + path: /m/{name}/pinned + methods: [GET] + condition: '%kbin_ap_route_condition%' + ap_entry: controller: App\Controller\ActivityPub\EntryController defaults: { slug: -, sortBy: hot } diff --git a/migrations/Version20240615225744.php b/migrations/Version20240615225744.php new file mode 100644 index 000000000..07c3d181b --- /dev/null +++ b/migrations/Version20240615225744.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE magazine ADD ap_featured_url VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD ap_featured_url VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP ap_featured_url'); + $this->addSql('ALTER TABLE magazine DROP ap_featured_url'); + } +} diff --git a/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php b/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php new file mode 100644 index 000000000..e737a8db4 --- /dev/null +++ b/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php @@ -0,0 +1,60 @@ +getCollectionItems($magazine); + $response = new JsonResponse($data); + $response->headers->set('Content-Type', 'application/activity+json'); + + return $response; + } + + #[ArrayShape([ + '@context' => 'array', + 'type' => 'string', + 'id' => 'string', + 'totalItems' => 'int', + 'orderedItems' => 'array', + ])] + private function getCollectionItems(Magazine $magazine): array + { + $pinned = $this->entryRepository->findPinned($magazine); + + $items = []; + foreach ($pinned as $entry) { + $items[] = $this->entryFactory->create($entry, $this->tagLinkRepository->getTagsOfEntry($entry)); + } + + return [ + '@context' => [ActivityPubActivityInterface::CONTEXT_URL], + 'type' => 'OrderedCollection', + 'id' => $this->urlGenerator->generate('ap_magazine_pinned', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL), + 'totalItems' => \sizeof($items), + 'orderedItems' => $items, + ]; + } +} diff --git a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php index d74dce7a4..8b2ee9f54 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php @@ -73,7 +73,7 @@ public function __invoke( ): JsonResponse { $headers = $this->rateLimit($apiModerateLimiter); - $manager->pin($entry); + $manager->pin($entry, $this->getUserOrThrow()); return new JsonResponse( $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), diff --git a/src/Controller/Entry/EntryPinController.php b/src/Controller/Entry/EntryPinController.php index a074f9c42..2710df542 100644 --- a/src/Controller/Entry/EntryPinController.php +++ b/src/Controller/Entry/EntryPinController.php @@ -31,7 +31,7 @@ public function __invoke( ): Response { $this->validateCsrf('entry_pin', $request->request->get('token')); - $entry = $this->manager->pin($entry); + $entry = $this->manager->pin($entry, $this->getUserOrThrow()); $this->addFlash( 'success', diff --git a/src/DTO/MagazineDto.php b/src/DTO/MagazineDto.php index 447514f41..12c190b3a 100644 --- a/src/DTO/MagazineDto.php +++ b/src/DTO/MagazineDto.php @@ -46,6 +46,7 @@ class MagazineDto public ?string $ip = null; public ?string $apId = null; public ?string $apProfileId = null; + public ?string $apFeaturedUrl = null; private ?int $id = null; public function getId(): ?int diff --git a/src/Entity/MagazineLog.php b/src/Entity/MagazineLog.php index cc23bfa21..ef7b4ff64 100644 --- a/src/Entity/MagazineLog.php +++ b/src/Entity/MagazineLog.php @@ -25,6 +25,8 @@ 'entry_restored' => MagazineLogEntryRestored::class, 'entry_comment_deleted' => MagazineLogEntryCommentDeleted::class, 'entry_comment_restored' => MagazineLogEntryCommentRestored::class, + 'entry_pinned' => MagazineLogEntryPinned::class, + 'entry_unpinned' => MagazineLogEntryUnpinned::class, 'post_deleted' => MagazineLogPostDeleted::class, 'post_restored' => MagazineLogPostRestored::class, 'post_comment_deleted' => MagazineLogPostCommentDeleted::class, diff --git a/src/Entity/MagazineLogEntryPinned.php b/src/Entity/MagazineLogEntryPinned.php new file mode 100644 index 000000000..e4948152e --- /dev/null +++ b/src/Entity/MagazineLogEntryPinned.php @@ -0,0 +1,46 @@ +user); + $this->entry = $unpinnedEntry; + $this->actingUser = $actingUser; + } + + public function getSubject(): ContentInterface|null + { + return $this->entry; + } + + public function clearSubject(): MagazineLog + { + $this->entry = null; + + return $this; + } + + public function getType(): string + { + return 'log_entry_pinned'; + } +} diff --git a/src/Entity/MagazineLogEntryUnpinned.php b/src/Entity/MagazineLogEntryUnpinned.php new file mode 100644 index 000000000..73c107b7a --- /dev/null +++ b/src/Entity/MagazineLogEntryUnpinned.php @@ -0,0 +1,46 @@ +user); + $this->entry = $unpinnedEntry; + $this->actingUser = $actingUser; + } + + public function getSubject(): ContentInterface|null + { + return $this->entry; + } + + public function clearSubject(): MagazineLog + { + $this->entry = null; + + return $this; + } + + public function getType(): string + { + return 'log_entry_unpinned'; + } +} diff --git a/src/Entity/Traits/ActivityPubActorTrait.php b/src/Entity/Traits/ActivityPubActorTrait.php index 0748cd9be..c21e4c933 100644 --- a/src/Entity/Traits/ActivityPubActorTrait.php +++ b/src/Entity/Traits/ActivityPubActorTrait.php @@ -23,6 +23,9 @@ trait ActivityPubActorTrait #[Column(type: 'string', nullable: true)] public ?string $apAttributedToUrl = null; + #[Column(type: 'string', nullable: true)] + public ?string $apFeaturedUrl = null; + #[Column(type: 'integer', nullable: true)] public ?int $apFollowersCount = null; diff --git a/src/Event/Entry/EntryPinEvent.php b/src/Event/Entry/EntryPinEvent.php index 465855fcc..aecaca824 100644 --- a/src/Event/Entry/EntryPinEvent.php +++ b/src/Event/Entry/EntryPinEvent.php @@ -5,10 +5,11 @@ namespace App\Event\Entry; use App\Entity\Entry; +use App\Entity\User; class EntryPinEvent { - public function __construct(public Entry $entry) + public function __construct(public Entry $entry, public ?User $actor) { } } diff --git a/src/EventSubscriber/Entry/EntryPinSubscriber.php b/src/EventSubscriber/Entry/EntryPinSubscriber.php new file mode 100644 index 000000000..c65e1b754 --- /dev/null +++ b/src/EventSubscriber/Entry/EntryPinSubscriber.php @@ -0,0 +1,51 @@ + 'onEntryPin', + ]; + } + + public function onEntryPin(EntryPinEvent $event): void + { + if ($event->actor && null === $event->actor->apId && $event->entry->magazine->userIsModerator($event->actor)) { + $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']); + $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId())); + } elseif (null === $event->entry->magazine->apId && $event->actor && $event->entry->magazine->userIsModerator($event->actor)) { + if (null !== $event->actor->apId) { + if ($event->entry->sticky) { + $activity = $this->addRemoveFactory->buildAddPinnedPost($event->actor, $event->entry); + } else { + $activity = $this->addRemoveFactory->buildRemovePinnedPost($event->actor, $event->entry); + } + $this->logger->debug('dispatching announce for add pin post {e} by {u} in {m}', ['e' => $event->entry->title, 'u' => $event->actor->apId, 'm' => $event->entry->magazine->name]); + $this->bus->dispatch(new GenericAnnounceMessage($event->entry->magazine->getId(), $activity)); + } else { + $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']); + $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId())); + } + } + } +} diff --git a/src/Factory/ActivityPub/AddRemoveFactory.php b/src/Factory/ActivityPub/AddRemoveFactory.php index 898bb1ab0..663e2f2bd 100644 --- a/src/Factory/ActivityPub/AddRemoveFactory.php +++ b/src/Factory/ActivityPub/AddRemoveFactory.php @@ -5,6 +5,7 @@ namespace App\Factory\ActivityPub; use App\Entity\Contracts\ActivityPubActivityInterface; +use App\Entity\Entry; use App\Entity\Magazine; use App\Entity\User; use App\Service\ActivityPub\ContextsProvider; @@ -20,14 +21,52 @@ public function __construct( ) { } - public function buildAdd(User $actor, User $added, Magazine $magazine): array + public function buildAddModerator(User $actor, User $added, Magazine $magazine): array { - return $this->build($actor, $added, $magazine, 'Add'); + $url = $magazine->apAttributedToUrl ?? $this->urlGenerator->generate( + 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + $addedUserUrl = null !== $added->apId ? $added->apPublicUrl : $this->urlGenerator->generate( + 'ap_user', ['username' => $added->username], UrlGeneratorInterface::ABSOLUTE_URL + ); + + return $this->build($actor, $addedUserUrl, $magazine, 'Add', $url); + } + + public function buildRemoveModerator(User $actor, User $removed, Magazine $magazine): array + { + $url = $magazine->apAttributedToUrl ?? $this->urlGenerator->generate( + 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + $removedUserUrl = null !== $removed->apId ? $removed->apPublicUrl : $this->urlGenerator->generate( + 'ap_user', ['username' => $removed->username], UrlGeneratorInterface::ABSOLUTE_URL + ); + + return $this->build($actor, $removedUserUrl, $magazine, 'Remove', $url); + } + + public function buildAddPinnedPost(User $actor, Entry $added): array + { + $url = null !== $added->magazine->apId ? $added->magazine->apFeaturedUrl : $this->urlGenerator->generate( + 'ap_magazine_pinned', ['name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + $entryUrl = $added->apId ?? $this->urlGenerator->generate( + 'ap_entry', ['entry_id' => $added->getId(), 'magazine_name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + + return $this->build($actor, $entryUrl, $added->magazine, 'Add', $url); } - public function buildRemove(User $actor, User $removed, Magazine $magazine): array + public function buildRemovePinnedPost(User $actor, Entry $removed): array { - return $this->build($actor, $removed, $magazine, 'Remove'); + $url = null !== $removed->magazine->apId ? $removed->magazine->apFeaturedUrl : $this->urlGenerator->generate( + 'ap_magazine_pinned', ['name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + $entryUrl = $removed->apId ?? $this->urlGenerator->generate( + 'ap_entry', ['entry_id' => $removed->getId(), 'magazine_name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL + ); + + return $this->build($actor, $entryUrl, $removed->magazine, 'Remove', $url); } #[ArrayShape([ @@ -41,7 +80,7 @@ public function buildRemove(User $actor, User $removed, Magazine $magazine): arr 'target' => 'string', 'audience' => 'string', ])] - private function build(User $actor, User $targetUser, Magazine $magazine, string $type): array + private function build(User $actor, string $targetObjectUrl, Magazine $magazine, string $type, string $collectionUrl): array { $id = Uuid::v4()->toRfc4122(); @@ -50,23 +89,19 @@ private function build(User $actor, User $targetUser, Magazine $magazine, string 'id' => $this->urlGenerator->generate( 'ap_object', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL ), - 'actor' => $actor->apId ?? $this->urlGenerator->generate( + 'actor' => null !== $actor->apId ? $actor->apPublicUrl : $this->urlGenerator->generate( 'ap_user', ['username' => $actor->username], UrlGeneratorInterface::ABSOLUTE_URL ), 'to' => [ActivityPubActivityInterface::PUBLIC_URL], - 'object' => $targetUser->apId ?? $this->urlGenerator->generate( - 'ap_user', ['username' => $targetUser->username], UrlGeneratorInterface::ABSOLUTE_URL - ), + 'object' => $targetObjectUrl, 'cc' => [ - $magazine->apId ?? $this->urlGenerator->generate( + null !== $magazine->apId ? $magazine->apPublicUrl : $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), ], 'type' => $type, - 'target' => $magazine->apAttributedToUrl ?? $this->urlGenerator->generate( - 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL - ), - 'audience' => $magazine->apId ?? $this->urlGenerator->generate( + 'target' => $collectionUrl, + 'audience' => null !== $magazine->apId ? $magazine->apPublicUrl : $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), ]; diff --git a/src/Factory/ActivityPub/GroupFactory.php b/src/Factory/ActivityPub/GroupFactory.php index 5887e0650..9b64b7ccd 100644 --- a/src/Factory/ActivityPub/GroupFactory.php +++ b/src/Factory/ActivityPub/GroupFactory.php @@ -55,6 +55,11 @@ public function create(Magazine $magazine): array ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), + 'featured' => $this->urlGenerator->generate( + 'ap_magazine_pinned', + ['name' => $magazine->name], + UrlGeneratorInterface::ABSOLUTE_URL + ), 'url' => $this->getActivityPubId($magazine), 'publicKey' => [ 'owner' => $this->getActivityPubId($magazine), diff --git a/src/Factory/MagazineFactory.php b/src/Factory/MagazineFactory.php index 6da8db0d5..e376501bb 100644 --- a/src/Factory/MagazineFactory.php +++ b/src/Factory/MagazineFactory.php @@ -65,6 +65,7 @@ public function createDto(Magazine $magazine): MagazineDto $dto->moderators = $magazine->moderators; $dto->apId = $magazine->apId; $dto->apProfileId = $magazine->apProfileId; + $dto->apFeaturedUrl = $magazine->apFeaturedUrl; $dto->setId($magazine->getId()); /** @var User $currentUser */ diff --git a/src/Message/ActivityPub/Inbox/CreateMessage.php b/src/Message/ActivityPub/Inbox/CreateMessage.php index bebfaeac0..c19e51a10 100644 --- a/src/Message/ActivityPub/Inbox/CreateMessage.php +++ b/src/Message/ActivityPub/Inbox/CreateMessage.php @@ -8,7 +8,7 @@ class CreateMessage implements ActivityPubInboxInterface { - public function __construct(public array $payload) + public function __construct(public array $payload, public ?bool $stickyIt = false) { } } diff --git a/src/Message/ActivityPub/Inbox/EntryPinMessage.php b/src/Message/ActivityPub/Inbox/EntryPinMessage.php new file mode 100644 index 000000000..44148415c --- /dev/null +++ b/src/Message/ActivityPub/Inbox/EntryPinMessage.php @@ -0,0 +1,14 @@ +payload; $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']); $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']); - if (!$targetMag) { - throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); + if ($targetMag) { + $this->handleModeratorAdd($targetMag, $actor, $payload['object']); + + return; + } + $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']); + if ($targetMag) { + $this->handlePinnedAdd($targetMag, $actor, $payload['object']); + + return; } + throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); + } + + public function handleModeratorAdd(Magazine $targetMag, Magazine|User $actor, $object1): void + { if (!$targetMag->userIsModerator($actor) and !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. He can therefore not add moderators"); } - $object = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['object']); + $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1); if ($targetMag->userIsModerator($object)) { $this->logger->warning('the user "{added}" ({addedId}) already is a moderator of "{magName}" ({magId}). Discarding message', [ @@ -57,4 +86,47 @@ public function __invoke(AddMessage $message): void ]); $this->magazineManager->addModerator(new ModeratorDto($targetMag, $object, $actor)); } + + private function handlePinnedAdd(Magazine $targetMag, User $actor, mixed $object): void + { + if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { + throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries"); + } + + $apId = null; + if (\is_string($object)) { + $apId = $object; + } elseif (\is_array($object)) { + $apId = $object['id']; + } else { + throw new \LogicException('the added object is neither a string or an array'); + } + + if ($this->settingsManager->isLocalUrl($apId)) { + $pair = $this->apActivityRepository->findLocalByApId($apId); + if (Entry::class === $pair['type']) { + $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]); + if ($existingEntry && !$existingEntry->sticky) { + $this->logger->info('pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); + $this->entryManager->pin($existingEntry, $actor); + } + } + } else { + $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); + if ($existingEntry) { + if (null !== $existingEntry->magazine->apFeaturedUrl) { + $this->apHttpClient->invalidateCollectionObjectCache($existingEntry->magazine->apFeaturedUrl); + } + if (!$existingEntry->sticky) { + $this->logger->info('pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); + $this->entryManager->pin($existingEntry, $actor); + } + } else { + if (!\is_array($object)) { + $object = $this->apHttpClient->getActivityObject($apId); + } + $this->bus->dispatch(new CreateMessage($object, true)); + } + } + } } diff --git a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php index 467fbe0dd..295270b48 100644 --- a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php @@ -24,6 +24,7 @@ class CreateHandler { private array $object; + private bool $stickyIt; public function __construct( private readonly Note $note, @@ -42,6 +43,7 @@ public function __construct( public function __invoke(CreateMessage $message): void { $this->object = $message->payload; + $this->stickyIt = $message->stickyIt; $this->logger->debug('Got a CreateMessage of type {t}, {m}', ['t' => $message->payload['type'], 'm' => $message->payload]); $entryTypes = ['Page', 'Article', 'Video']; $postTypes = ['Question', 'Note']; @@ -72,7 +74,7 @@ private function handleChain(): void } } - $note = $this->note->create($this->object); + $note = $this->note->create($this->object, stickyIt: $this->stickyIt); // TODO atm post and post comment are not announced, because of the micro blog spam towards lemmy. If we implement magazine name as hashtag to be optional than this may be reverted if ($note instanceof EntryComment /* or $note instanceof Post or $note instanceof PostComment */) { if (null !== $note->apId and null === $note->magazine->apId and 'random' !== $note->magazine->name) { @@ -89,7 +91,7 @@ private function handleChain(): void */ private function handlePage(): void { - $page = $this->page->create($this->object); + $page = $this->page->create($this->object, stickyIt: $this->stickyIt); if ($page instanceof Entry) { if (null !== $page->apId and null === $page->magazine->apId and 'random' !== $page->magazine->name) { // local magazine, but remote post. Random magazine is ignored, as it should not be federated at all diff --git a/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php index cd746626c..51e8d9b8e 100644 --- a/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php @@ -4,10 +4,17 @@ namespace App\MessageHandler\ActivityPub\Inbox; +use App\Entity\Entry; +use App\Entity\Magazine; +use App\Entity\User; use App\Message\ActivityPub\Inbox\RemoveMessage; +use App\Repository\ApActivityRepository; +use App\Repository\EntryRepository; use App\Repository\MagazineRepository; use App\Service\ActivityPubManager; +use App\Service\EntryManager; use App\Service\MagazineManager; +use App\Service\SettingsManager; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -15,10 +22,14 @@ class RemoveHandler { public function __construct( + private readonly ApActivityRepository $apActivityRepository, private readonly ActivityPubManager $activityPubManager, private readonly MagazineRepository $magazineRepository, private readonly MagazineManager $magazineManager, private readonly LoggerInterface $logger, + private readonly EntryRepository $entryRepository, + private readonly EntryManager $entryManager, + private readonly SettingsManager $settingsManager, ) { } @@ -27,14 +38,27 @@ public function __invoke(RemoveMessage $message): void $payload = $message->payload; $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']); $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']); - if (!$targetMag) { - throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); + if ($targetMag) { + $this->handleModeratorRemove($payload['object'], $targetMag, $actor); + + return; + } + $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']); + if ($targetMag) { + $this->handlePinnedRemove($payload['object'], $targetMag, $actor); + + return; } + throw new \LogicException("could not find a magazine with moderators url like: '{$payload['target']}'"); + } + + public function handleModeratorRemove($object1, Magazine $targetMag, Magazine|User $actor): void + { if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. He can therefore not remove moderators"); } - $object = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['object']); + $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1); $objectMod = $targetMag->getUserAsModeratorOrNull($object); $loggerParams = [ @@ -64,4 +88,36 @@ public function __invoke(RemoveMessage $message): void ]); $this->magazineManager->removeModerator($objectMod, $actor); } + + private function handlePinnedRemove(mixed $object, Magazine $targetMag, User $actor): void + { + if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) { + throw new \LogicException("the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries"); + } + + $apId = null; + if (\is_string($object)) { + $apId = $object; + } elseif (\is_array($object)) { + $apId = $object['id']; + } else { + throw new \LogicException('the added object is neither a string or an array'); + } + if ($this->settingsManager->isLocalUrl($apId)) { + $pair = $this->apActivityRepository->findLocalByApId($apId); + if (Entry::class === $pair['type']) { + $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]); + if ($existingEntry && $existingEntry->sticky) { + $this->logger->info('unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); + $this->entryManager->pin($existingEntry, $actor); + } + } + } else { + $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); + if ($existingEntry && $existingEntry->sticky) { + $this->logger->info('unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]); + $this->entryManager->pin($existingEntry, $actor); + } + } + } } diff --git a/src/MessageHandler/ActivityPub/Outbox/AddHandler.php b/src/MessageHandler/ActivityPub/Outbox/AddHandler.php index b37e893f4..cac88fce6 100644 --- a/src/MessageHandler/ActivityPub/Outbox/AddHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/AddHandler.php @@ -39,7 +39,7 @@ public function __invoke(AddMessage $message): void $audience = $this->magazineRepository->findAudience($magazine); } - $activity = $this->factory->buildAdd($actor, $added, $magazine); + $activity = $this->factory->buildAddModerator($actor, $added, $magazine); $this->deliverManager->deliver($audience, $activity); } } diff --git a/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php new file mode 100644 index 000000000..2d1d1aefd --- /dev/null +++ b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php @@ -0,0 +1,59 @@ +settingsManager->get('KBIN_FEDERATION_ENABLED')) { + return; + } + $entry = $this->entryRepository->findOneBy(['id' => $message->entryId]); + $user = $this->userRepository->findOneBy(['id' => $message->actorId]); + + if (null !== $entry->magazine->apId && null !== $user->apId) { + $this->logger->warning('got an EntryPinMessage for remote magazine {m} by remote user {u}. That does not need to be propagated, as this instance is not the source', ['m' => $entry->magazine->apId, 'u' => $user->apId]); + + return; + } + + if ($message->sticky) { + $activity = $this->addRemoveFactory->buildAddPinnedPost($user, $entry); + } else { + $activity = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry); + } + + if ($entry->magazine->apId) { + $audience = [$entry->magazine->apInboxUrl]; + } else { + $audience = $this->magazineRepository->findAudience($entry->magazine); + } + + $this->deliverManager->deliver($audience, $activity); + } +} diff --git a/src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php b/src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php new file mode 100644 index 000000000..791b25ce1 --- /dev/null +++ b/src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php @@ -0,0 +1,41 @@ +settingsManager->get('KBIN_FEDERATION_ENABLED')) { + return; + } + $magazine = $this->magazineRepository->find($message->announcingMagazineId); + if (null !== $magazine->apId) { + return; + } + $magazineUrl = $this->urlGenerator->generate('ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL); + $announce = $this->announceWrapper->build($magazineUrl, $message->payloadToAnnounce); + $inboxes = $this->magazineRepository->findAudience($magazine); + $this->deliverManager->deliver($inboxes, $announce); + } +} diff --git a/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php b/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php index 98aa53f5b..e20af0c89 100644 --- a/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php @@ -39,7 +39,7 @@ public function __invoke(RemoveMessage $message): void $audience = $this->magazineRepository->findAudience($magazine); } - $activity = $this->factory->buildRemove($actor, $removed, $magazine); + $activity = $this->factory->buildRemoveModerator($actor, $removed, $magazine); $this->deliverManager->deliver($audience, $activity); } } diff --git a/src/Repository/ApActivityRepository.php b/src/Repository/ApActivityRepository.php index 4e15be12d..6da0d400c 100644 --- a/src/Repository/ApActivityRepository.php +++ b/src/Repository/ApActivityRepository.php @@ -74,7 +74,7 @@ public function findByObjectId(string $apId): ?array 'id' => 'int', 'type' => 'string', ])] - private function findLocalByApId(string $apId): ?array + public function findLocalByApId(string $apId): ?array { $parsed = parse_url($apId); if ($parsed['host'] === $this->settingsManager->get('KBIN_DOMAIN')) { diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 7fba4188c..7dfe99fd5 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -191,10 +191,10 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder if ($criteria->subscribed) { $qb->andWhere( - 'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) - OR + 'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) + OR e.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user) - OR + OR e.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :user) OR e.user = :user' @@ -410,6 +410,20 @@ public function findLast(int $limit): array ->getResult(); } + /** + * @return Entry[] + */ + public function findPinned(Magazine $magazine): array + { + return $this->createQueryBuilder('e') + ->where('e.magazine = :m') + ->andWhere('e.sticky = true') + ->setParameter('m', $magazine) + ->getQuery() + ->getResult() + ; + } + private function countAll(EntryPageView|Criteria $criteria): int { return $this->cache->get( diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 6c70535c7..930819ab8 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -597,4 +597,20 @@ public function getMagazineFromModeratorsUrl($target): ?Magazine return null; } + + public function getMagazineFromPinnedUrl($target): ?Magazine + { + if ($this->settingsManager->isLocalUrl($target)) { + $matches = []; + if (preg_match_all("/\/m\/([a-zA-Z0-9\-_:]+)\/pinned/", $target, $matches)) { + $magName = $matches[1][0]; + + return $this->findOneByName($magName); + } + } else { + return $this->findOneBy(['apFeaturedUrl' => $target]); + } + + return null; + } } diff --git a/src/Service/ActivityPub/ApHttpClient.php b/src/Service/ActivityPub/ApHttpClient.php index 33984743f..66b2b1833 100644 --- a/src/Service/ActivityPub/ApHttpClient.php +++ b/src/Service/ActivityPub/ApHttpClient.php @@ -210,6 +210,11 @@ function (ItemInterface $item) use ($apProfileId) { return $resp ? json_decode($resp, true) : null; } + public function invalidateCollectionObjectCache(string $apAddress): void + { + $this->cache->delete('ap_collection'.hash('sha256', $apAddress)); + } + /** * @throws InvalidArgumentException */ diff --git a/src/Service/ActivityPub/Note.php b/src/Service/ActivityPub/Note.php index 4f130a7bc..f1011b0c0 100644 --- a/src/Service/ActivityPub/Note.php +++ b/src/Service/ActivityPub/Note.php @@ -48,7 +48,7 @@ public function __construct( * @throws UserBannedException * @throws \Exception */ - public function create(array $object, array $root = null): EntryComment|PostComment|Post + public function create(array $object, array $root = null, bool $stickyIt = false): EntryComment|PostComment|Post { $current = $this->repository->findByObjectId($object['id']); if ($current) { @@ -93,7 +93,7 @@ public function create(array $object, array $root = null): EntryComment|PostComm } } - return $this->createPost($object); + return $this->createPost($object, $stickyIt); } /** @@ -184,7 +184,7 @@ private function handleSensitiveMedia(PostDto|PostCommentDto|EntryCommentDto|Ent } } - private function createPost(array $object): Post + private function createPost(array $object, bool $stickyIt = false): Post { $dto = new PostDto(); $dto->magazine = $this->activityPubManager->findOrCreateMagazineByToCCAndAudience($object); @@ -223,7 +223,7 @@ private function createPost(array $object): Post $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object); $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object); - return $this->postManager->create($dto, $actor, false); + return $this->postManager->create($dto, $actor, false, $stickyIt); } else { throw new \Exception('Actor could not be found for post.'); } diff --git a/src/Service/ActivityPub/Page.php b/src/Service/ActivityPub/Page.php index aa3dd1f62..c950d6e24 100644 --- a/src/Service/ActivityPub/Page.php +++ b/src/Service/ActivityPub/Page.php @@ -42,7 +42,7 @@ public function __construct( * @throws UserBannedException * @throws \Exception if the user could not be found or a sub exception occurred */ - public function create(array $object): Entry + public function create(array $object, bool $stickyIt = false): Entry { $actorUrl = $this->activityPubManager->getActorFromAttributedTo($object['attributedTo']); $actor = $this->activityPubManager->findActorOrCreate($actorUrl); @@ -102,7 +102,7 @@ public function create(array $object): Entry $this->logger->debug('creating page'); - return $this->entryManager->create($dto, $actor, false); + return $this->entryManager->create($dto, $actor, false, $stickyIt); } else { throw new \Exception('Actor could not be found for entry.'); } diff --git a/src/Service/ActivityPubManager.php b/src/Service/ActivityPubManager.php index a6c74dae2..8d7be204d 100644 --- a/src/Service/ActivityPubManager.php +++ b/src/Service/ActivityPubManager.php @@ -21,10 +21,12 @@ use App\Factory\ActivityPub\PersonFactory; use App\Factory\MagazineFactory; use App\Factory\UserFactory; +use App\Message\ActivityPub\Inbox\CreateMessage; use App\Message\ActivityPub\UpdateActorMessage; use App\Message\DeleteImageMessage; use App\Message\DeleteUserMessage; use App\Repository\ApActivityRepository; +use App\Repository\EntryRepository; use App\Repository\ImageRepository; use App\Repository\MagazineRepository; use App\Repository\UserRepository; @@ -35,6 +37,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\EntityManagerInterface; use League\HTMLToMarkdown\HtmlConverter; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\RateLimiter\RateLimiterFactory; @@ -62,6 +65,8 @@ public function __construct( private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger, private readonly RateLimiterFactory $apUpdateActorLimiter, + private readonly EntryRepository $entryRepository, + private readonly EntryManager $entryManager, ) { } @@ -491,6 +496,7 @@ public function updateMagazine(string $actorUrl): ?Magazine $magazine->apDomain = parse_url($actor['id'], PHP_URL_HOST); $magazine->apFollowersUrl = $actor['followers'] ?? null; $magazine->apAttributedToUrl = $this->getActorFromAttributedTo($actor['attributedTo'] ?? null, filterForPerson: false); + $magazine->apFeaturedUrl = $actor['featured'] ?? null; $magazine->apPreferredUsername = $actor['preferredUsername'] ?? null; $magazine->apDiscoverable = $actor['discoverable'] ?? true; $magazine->apPublicUrl = $actor['url'] ?? $actorUrl; @@ -512,76 +518,175 @@ public function updateMagazine(string $actorUrl): ?Magazine } if (null !== $magazine->apAttributedToUrl) { - try { - $this->logger->debug('fetching moderators of remote magazine "{magUrl}"', ['magUrl' => $actorUrl]); - $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apAttributedToUrl); - $items = null; - if (isset($attributedObj['items']) and \is_array($attributedObj['items'])) { - $items = $attributedObj['items']; - } elseif (isset($attributedObj['orderedItems']) and \is_array($attributedObj['orderedItems'])) { - $items = $attributedObj['orderedItems']; - } + $this->handleModeratorCollection($actorUrl, $magazine); + } + + if (null !== $magazine->apFeaturedUrl) { + $this->handleMagazineFeaturedCollection($actorUrl, $magazine); + } - $this->logger->debug('got moderator items for magazine "{magName}": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]); + $this->entityManager->flush(); - if (null !== $items) { - $moderatorsToRemove = []; - /** @var Moderator $mod */ - foreach ($magazine->moderators as $mod) { - $moderatorsToRemove[] = $mod->user; - } - $indexesNotToRemove = []; - - foreach ($items as $item) { - if (\is_string($item)) { - try { - $user = $this->findActorOrCreate($item); - if ($user instanceof User) { - foreach ($moderatorsToRemove as $key => $existMod) { - if ($existMod->username === $user->username) { - $indexesNotToRemove[] = $key; - break; - } - } - if (!$magazine->userIsModerator($user)) { - $this->logger->info('adding "{user}" as moderator in "{magName}" because they are a mod upstream, but not locally', ['user' => $user->username, 'magName' => $magazine->name]); - $this->magazineManager->addModerator(new ModeratorDto($magazine, $user, null)); - } + return $magazine; + } else { + $this->logger->debug("ActivityPubManager:updateMagazine:actorUrl: $actorUrl. Actor not found."); + } + + return null; + } + + /** + * @throws InvalidArgumentException + */ + private function handleModeratorCollection(string $actorUrl, Magazine $magazine): void + { + try { + $this->logger->debug('fetching moderators of remote magazine "{magUrl}"', ['magUrl' => $actorUrl]); + $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apAttributedToUrl); + $items = null; + if (isset($attributedObj['items']) and \is_array($attributedObj['items'])) { + $items = $attributedObj['items']; + } elseif (isset($attributedObj['orderedItems']) and \is_array($attributedObj['orderedItems'])) { + $items = $attributedObj['orderedItems']; + } + + $this->logger->debug('got moderator items for magazine "{magName}": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]); + + if (null !== $items) { + $moderatorsToRemove = []; + /** @var Moderator $mod */ + foreach ($magazine->moderators as $mod) { + $moderatorsToRemove[] = $mod->user; + } + $indexesNotToRemove = []; + + foreach ($items as $item) { + if (\is_string($item)) { + try { + $user = $this->findActorOrCreate($item); + if ($user instanceof User) { + foreach ($moderatorsToRemove as $key => $existMod) { + if ($existMod->username === $user->username) { + $indexesNotToRemove[] = $key; + break; } - } catch (\Exception) { - $this->logger->warning('Something went wrong while fetching actor "{actor}" as moderator of "{magName}"', ['actor' => $item, 'magName' => $magazine->name]); + } + if (!$magazine->userIsModerator($user)) { + $this->logger->info('adding "{user}" as moderator in "{magName}" because they are a mod upstream, but not locally', ['user' => $user->username, 'magName' => $magazine->name]); + $this->magazineManager->addModerator(new ModeratorDto($magazine, $user, null)); } } + } catch (\Exception) { + $this->logger->warning('Something went wrong while fetching actor "{actor}" as moderator of "{magName}"', ['actor' => $item, 'magName' => $magazine->name]); } + } + } - foreach ($indexesNotToRemove as $i) { - $moderatorsToRemove[$i] = null; - } + foreach ($indexesNotToRemove as $i) { + $moderatorsToRemove[$i] = null; + } + + foreach ($moderatorsToRemove as $modToRemove) { + if (null === $modToRemove) { + continue; + } + $criteria = Criteria::create()->where(Criteria::expr()->eq('magazine', $magazine)); + $modObject = $modToRemove->moderatorTokens->matching($criteria)->first(); + $this->logger->info('removing "{exMod}" from "{magName}" as mod locally because they are no longer mod upstream', ['exMod' => $modToRemove->username, 'magName' => $magazine->name]); + $this->magazineManager->removeModerator($modObject, null); + } + } else { + $this->logger->warning('could not update the moderators of "{url}", the response doesn\'t have a "items" or "orderedItems" property or it is not an array', ['url' => $actorUrl]); + } + } catch (InvalidApPostException $ignored) { + } + } + + /** + * @throws InvalidArgumentException + */ + private function handleMagazineFeaturedCollection(string $actorUrl, Magazine $magazine): void + { + try { + $this->logger->debug('fetching featured posts of remote magazine "{magUrl}"', ['magUrl' => $actorUrl]); + $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apFeaturedUrl); + $items = null; + if (isset($attributedObj['items']) and \is_array($attributedObj['items'])) { + $items = $attributedObj['items']; + } elseif (isset($attributedObj['orderedItems']) and \is_array($attributedObj['orderedItems'])) { + $items = $attributedObj['orderedItems']; + } - foreach ($moderatorsToRemove as $modToRemove) { - if (null === $modToRemove) { - continue; + $this->logger->debug('got featured items for magazine "{magName}": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]); + + if (null !== $items) { + $pinnedToRemove = $this->entryRepository->findPinned($magazine); + $indexesNotToRemove = []; + $idsToPin = []; + foreach ($items as $item) { + $apId = null; + $isString = false; + if (\is_string($item)) { + $apId = $item; + $isString = true; + } elseif (\is_array($item)) { + $apId = $item['id']; + } else { + $this->logger->debug('ignoring {item} because it is not a string and not an array', ['item' => json_encode($item)]); + continue; + } + + $entry = null; + + $alreadyPinned = false; + if ($this->settingsManager->isLocalUrl($apId)) { + $pair = $this->activityRepository->findLocalByApId($apId); + if (Entry::class === $pair['type']) { + foreach ($pinnedToRemove as $i => $entry) { + if ($entry->getId() === $pair['id']) { + $indexesNotToRemove[] = $i; + $alreadyPinned = true; + } } - $criteria = Criteria::create()->where(Criteria::expr()->eq('magazine', $magazine)); - $modObject = $modToRemove->moderatorTokens->matching($criteria)->first(); - $this->logger->info('removing "{exMod}" from "{magName}" as mod locally because they are no longer mod upstream', ['exMod' => $modToRemove->username, 'magName' => $magazine->name]); - $this->magazineManager->removeModerator($modObject, null); } } else { - $this->logger->warning('could not update the moderators of "{url}", the response doesn\'t have a "items" or "orderedItems" property or it is not an array', ['url' => $actorUrl]); + foreach ($pinnedToRemove as $i => $entry) { + if ($entry->apId === $apId) { + $indexesNotToRemove[] = $i; + $alreadyPinned = true; + } + } + + if (!$alreadyPinned) { + $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]); + if ($existingEntry) { + $this->logger->debug('pinning existing entry: {title}', ['title' => $existingEntry->title]); + $this->entryManager->pin($existingEntry, null); + } else { + $object = $item; + if ($isString) { + $this->logger->debug('getting {url} because we dont have it', ['url' => $apId]); + $object = $this->apHttpClient->getActivityObject($apId); + } + $this->logger->debug('dispatching create message for entry: {e}', ['e' => json_encode($object)]); + $this->bus->dispatch(new CreateMessage($object, true)); + } + } } - } catch (InvalidApPostException $ignored) { } - } - $this->entityManager->flush(); + foreach ($indexesNotToRemove as $i) { + $pinnedToRemove[$i] = null; + } - return $magazine; - } else { - $this->logger->debug("ActivityPubManager:updateMagazine:actorUrl: $actorUrl. Actor not found."); + foreach (array_filter($pinnedToRemove) as $pinnedEntry) { + // the pin method also unpins if the entry is already pinned + $this->logger->debug('unpinning entry: {title}', ['title' => $pinnedEntry->title]); + $this->entryManager->pin($pinnedEntry, null); + } + } + } catch (InvalidApPostException $ignored) { } - - return null; } public function createInboxesFromCC(array $activity, User $user): array diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index cefc49b88..e12c88dab 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -8,6 +8,8 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\Entry; use App\Entity\Magazine; +use App\Entity\MagazineLogEntryPinned; +use App\Entity\MagazineLogEntryUnpinned; use App\Entity\User; use App\Event\Entry\EntryBeforeDeletedEvent; use App\Event\Entry\EntryBeforePurgeEvent; @@ -22,6 +24,7 @@ use App\Message\DeleteImageMessage; use App\Repository\EntryRepository; use App\Repository\ImageRepository; +use App\Service\ActivityPub\ApHttpClient; use App\Service\Contracts\ContentManagerInterface; use App\Utils\Slugger; use App\Utils\UrlCleaner; @@ -55,6 +58,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly EntryRepository $entryRepository, private readonly ImageRepository $imageRepository, + private readonly ApHttpClient $apHttpClient, private readonly CacheInterface $cache ) { } @@ -65,7 +69,7 @@ public function __construct( * @throws TooManyRequestsHttpException * @throws \Exception if title, body and image are empty */ - public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry + public function create(EntryDto $dto, User $user, bool $rateLimit = true, bool $stickyIt = false): Entry { if ($rateLimit) { $limiter = $this->entryLimiter->create($dto->ip); @@ -123,6 +127,10 @@ public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry $this->dispatcher->dispatch(new EntryCreatedEvent($entry)); + if ($stickyIt) { + $this->pin($entry, null); + } + return $entry; } @@ -267,13 +275,29 @@ public function restore(User $user, Entry $entry): void $this->dispatcher->dispatch(new EntryRestoredEvent($entry, $user)); } - public function pin(Entry $entry): Entry + /** + * this toggles the pin state of the entry. If it was not pinned it pins, if it was pinned it unpins it. + * + * @param User|null $actor this should only be null if it is a system call + */ + public function pin(Entry $entry, ?User $actor): Entry { $entry->sticky = !$entry->sticky; + if ($entry->sticky) { + $log = new MagazineLogEntryPinned($entry->magazine, $actor, $entry); + } else { + $log = new MagazineLogEntryUnpinned($entry->magazine, $actor, $entry); + } + $this->entityManager->persist($log); + $this->entityManager->flush(); - $this->dispatcher->dispatch(new EntryPinEvent($entry)); + $this->dispatcher->dispatch(new EntryPinEvent($entry, $actor)); + + if (null !== $entry->magazine->apFeaturedUrl) { + $this->apHttpClient->invalidateCollectionObjectCache($entry->magazine->apFeaturedUrl); + } return $entry; } diff --git a/src/Service/MagazineManager.php b/src/Service/MagazineManager.php index d81d6c63e..be2ab4ff8 100644 --- a/src/Service/MagazineManager.php +++ b/src/Service/MagazineManager.php @@ -72,6 +72,7 @@ public function create(MagazineDto $dto, ?User $user, bool $rateLimit = true): M $magazine = $this->factory->createFromDto($dto, $user); $magazine->apId = $dto->apId; $magazine->apProfileId = $dto->apProfileId; + $magazine->apFeaturedUrl = $dto->apFeaturedUrl; if (!$dto->apId) { $magazine = KeysGenerator::generate($magazine); diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index ee5824b0a..06f25da62 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -21,6 +21,7 @@ use App\Message\DeleteImageMessage; use App\Repository\ImageRepository; use App\Repository\PostRepository; +use App\Service\ActivityPub\ApHttpClient; use App\Service\Contracts\ContentManagerInterface; use App\Utils\Slugger; use Doctrine\Common\Collections\Criteria; @@ -51,6 +52,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly PostRepository $postRepository, private readonly ImageRepository $imageRepository, + private readonly ApHttpClient $apHttpClient, private readonly CacheInterface $cache ) { } @@ -61,7 +63,7 @@ public function __construct( * @throws TooManyRequestsHttpException * @throws \Exception */ - public function create(PostDto $dto, User $user, $rateLimit = true): Post + public function create(PostDto $dto, User $user, $rateLimit = true, bool $stickyIt = false): Post { if ($rateLimit) { $limiter = $this->postLimiter->create($dto->ip); @@ -112,6 +114,10 @@ public function create(PostDto $dto, User $user, $rateLimit = true): Post $this->dispatcher->dispatch(new PostCreatedEvent($post)); + if ($stickyIt) { + $this->pin($post); + } + return $post; } @@ -224,12 +230,19 @@ public function restore(User $user, Post $post): void $this->dispatcher->dispatch(new PostRestoredEvent($post, $user)); } + /** + * this toggles the pin state of the post. If it was not pinned it pins, if it was pinned it unpins it. + */ public function pin(Post $post): Post { $post->sticky = !$post->sticky; $this->entityManager->flush(); + if (null !== $post->magazine->apFeaturedUrl) { + $this->apHttpClient->invalidateCollectionObjectCache($post->magazine->apFeaturedUrl); + } + return $post; } diff --git a/templates/modlog/_blocks.html.twig b/templates/modlog/_blocks.html.twig index 781abb443..a94b058ea 100644 --- a/templates/modlog/_blocks.html.twig +++ b/templates/modlog/_blocks.html.twig @@ -62,3 +62,25 @@ {{ 'magazine_log_mod_removed'|trans -}} {% if showMagazine %} {{ 'from'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons }) -}}{%- endif -%}: {{ component('user_inline', {user: log.user, showAvatar: showAvatars}) }} {% endblock %} + +{% block log_entry_pinned %} + {% if log.actingUser is not same as null %} + {{ component('user_inline', {user: log.actingUser}) }} + {% else %} + {{ 'someone'|trans }} + {% endif %} + {{ 'magazine_log_entry_pinned'|trans }} + {{ log.entry.shortTitle(300) }} + {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons }) -}}{%- endif -%} +{% endblock %} + +{% block log_entry_unpinned %} + {% if log.actingUser is not same as null %} + {{ component('user_inline', {user: log.actingUser}) }} + {% else %} + {{ 'someone'|trans }} + {% endif %} + {{ 'magazine_log_entry_unpinned'|trans }} + {{ log.entry.shortTitle(300) }} + {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons }) -}}{%- endif -%} +{% endblock %} diff --git a/tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php b/tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php index ca6d753eb..f6fa5175b 100644 --- a/tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php @@ -278,7 +278,7 @@ public function testApiCanGetEntriesAnonymous(): void $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($second); + $entryManager->pin($second, null); $client->request('GET', '/api/entries'); self::assertResponseIsSuccessful(); @@ -417,7 +417,7 @@ public function testApiCanGetEntriesWithLanguageAnonymous(): void $this->getEntryByTitle('a dutch entry', body: 'some body', magazine: $magazine, lang: 'nl'); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($second); + $entryManager->pin($second, null); $client->request('GET', '/api/entries?lang[]=en&lang[]=de'); self::assertResponseIsSuccessful(); @@ -558,7 +558,7 @@ public function testApiCannotGetEntriesByPreferredLangAnonymous(): void $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine); // Check that pinned entries don't get pinned to the top of the instance, just the magazine $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($second); + $entryManager->pin($second, null); $client->request('GET', '/api/entries?usePreferredLangs=true'); self::assertResponseStatusCodeSame(403); diff --git a/tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php b/tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php index f64e2826a..2f7a3bfda 100644 --- a/tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php @@ -93,7 +93,7 @@ public function testApiCanGetMagazineEntriesPinnedFirst(): void $voteManager->vote(1, $second, $voter, rateLimit: false); $this->createEntryComment('test', $second, $voter); $third = $this->getEntryByTitle('a pinned entry', url: 'https://google.com', magazine: $magazine); - $entryManager->pin($third); + $entryManager->pin($third, null); self::createOAuth2AuthCodeClient(); $client->loginUser($this->getUserByUsername('user')); diff --git a/tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php b/tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php index 7583cd024..6e99a2d01 100644 --- a/tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php +++ b/tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php @@ -114,7 +114,7 @@ public function testApiCannotUnpinEntryAnonymous(): void $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine); $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($entry); + $entryManager->pin($entry, null); $client->jsonRequest('PUT', "/api/moderate/entry/{$entry->getId()}/pin"); self::assertResponseStatusCodeSame(401); @@ -128,7 +128,7 @@ public function testApiNonModeratorCannotUnpinEntry(): void $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($entry); + $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $client->loginUser($user); @@ -148,7 +148,7 @@ public function testApiCannotUnpinEntryWithoutScope(): void $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($entry); + $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $client->loginUser($user); @@ -168,7 +168,7 @@ public function testApiCanUnpinEntry(): void $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine); $entryManager = $this->getService(EntryManager::class); - $entryManager->pin($entry); + $entryManager->pin($entry, null); self::createOAuth2AuthCodeClient(); $client->loginUser($user); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 979b6f8a3..6e7931c55 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -830,6 +830,8 @@ someone: Someone back: Back magazine_log_mod_added: has added a moderator magazine_log_mod_removed: has removed a moderator +magazine_log_entry_pinned: pinned entry +magazine_log_entry_unpinned: removed pinned entry last_updated: Last updated and: and direct_message: Direct message