From 1b96be2fa62babea3425cd17adad8ecac02e8ee2 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 16 Jun 2024 01:10:42 +0200 Subject: [PATCH 1/9] Federate pinned entries - add endpoint to get the featured collection - add outgoing messages to send collection updates to subscribed instances - handle the featured collection in the `ActivityPubManager.updateMagazine` method --- config/kbin_routes/activity_pub.yaml | 6 + migrations/Version20240615225744.php | 28 +++ .../Magazine/MagazinePinnedController.php | 61 +++++ .../Api/Entry/Moderate/EntriesPinApi.php | 2 +- src/Controller/Entry/EntryPinController.php | 2 +- src/DTO/MagazineDto.php | 1 + src/Entity/Traits/ActivityPubActorTrait.php | 3 + src/Event/Entry/EntryPinEvent.php | 3 +- .../Entry/EntryPinSubscriber.php | 32 +++ src/Factory/ActivityPub/AddRemoveFactory.php | 57 ++++- src/Factory/ActivityPub/GroupFactory.php | 5 + src/Factory/MagazineFactory.php | 1 + .../ActivityPub/Inbox/CreateMessage.php | 2 +- .../ActivityPub/Inbox/EntryPinMessage.php | 14 ++ .../ActivityPub/Outbox/EntryPinMessage.php | 14 ++ .../ActivityPub/Outbox/AddHandler.php | 2 +- .../Outbox/EntryPinMessageHandler.php | 55 +++++ .../ActivityPub/Outbox/RemoveHandler.php | 2 +- src/Repository/ApActivityRepository.php | 2 +- src/Repository/EntryRepository.php | 20 +- src/Service/ActivityPubManager.php | 213 +++++++++++++----- src/Service/EntryManager.php | 7 +- src/Service/MagazineManager.php | 1 + .../Api/Entry/EntryRetrieveApiTest.php | 6 +- .../Entry/MagazineEntryRetrieveApiTest.php | 2 +- .../Api/Entry/Moderate/EntryPinApiTest.php | 8 +- 26 files changed, 464 insertions(+), 85 deletions(-) create mode 100644 migrations/Version20240615225744.php create mode 100644 src/Controller/ActivityPub/Magazine/MagazinePinnedController.php create mode 100644 src/EventSubscriber/Entry/EntryPinSubscriber.php create mode 100644 src/Message/ActivityPub/Inbox/EntryPinMessage.php create mode 100644 src/Message/ActivityPub/Outbox/EntryPinMessage.php create mode 100644 src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php diff --git a/config/kbin_routes/activity_pub.yaml b/config/kbin_routes/activity_pub.yaml index 85425eaa2..79f4725b0 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..a694f66fe --- /dev/null +++ b/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php @@ -0,0 +1,61 @@ +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/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..6db7f3c0c --- /dev/null +++ b/src/EventSubscriber/Entry/EntryPinSubscriber.php @@ -0,0 +1,32 @@ + 'onEntryPin', + ]; + } + + public function onEntryPin(EntryPinEvent $event): void + { + if (null === $event->entry->magazine->apId || ($event->actor && null === $event->actor->apId && $event->entry->magazine->userIsModerator($event->actor))) { + $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..c01b696f7 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 = $targetUser->apId ?? $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 = $targetUser->apId ?? $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()], 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()], 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(); @@ -54,18 +93,14 @@ private function build(User $actor, User $targetUser, Magazine $magazine, string '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( '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 - ), + 'target' => $collectionUrl, 'audience' => $magazine->apId ?? $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 @@ +magazineRepository->findAudience($magazine); } - $activity = $this->factory->buildAdd($actor, $added, $magazine); + $activity = $this->factory->buildAddModerator($actor, $added, $magazine); foreach ($audience as $inboxUrl) { if (!$this->settingsManager->isBannedInstance($inboxUrl)) { $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity)); diff --git a/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php new file mode 100644 index 000000000..4548fcd85 --- /dev/null +++ b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php @@ -0,0 +1,55 @@ +settingsManager->get('KBIN_FEDERATION_ENABLED')) { + return; + } + $entry = $this->entryRepository->findOneBy(['id' => $message->entryId]); + $user = $this->userRepository->findOneBy(['id' => $message->actorId]); + 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); + } + + foreach ($audience as $inboxUrl) { + if (!$this->settingsManager->isBannedInstance($inboxUrl)) { + $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity)); + } + } + } +} diff --git a/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php b/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php index 544923666..e29a83538 100644 --- a/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php @@ -42,7 +42,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); foreach ($audience as $inboxUrl) { if (!$this->settingsManager->isBannedInstance($inboxUrl)) { $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity)); diff --git a/src/Repository/ApActivityRepository.php b/src/Repository/ApActivityRepository.php index 101603ace..190fc78f4 100644 --- a/src/Repository/ApActivityRepository.php +++ b/src/Repository/ApActivityRepository.php @@ -72,7 +72,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/Service/ActivityPubManager.php b/src/Service/ActivityPubManager.php index bb1b2a60d..922cb9baf 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, ) { } @@ -489,6 +494,7 @@ public function updateMagazine(string $actorUrl): ?Magazine $magazine->apDomain = parse_url($actor['id'], PHP_URL_HOST); $magazine->apFollowersUrl = $actor['followers'] ?? null; $magazine->apAttributedToUrl = $actor['attributedTo'] ?? null; + $magazine->apFeaturedUrl = $actor['featured'] ?? null; $magazine->apPreferredUsername = $actor['preferredUsername'] ?? null; $magazine->apDiscoverable = $actor['discoverable'] ?? true; $magazine->apPublicUrl = $actor['url'] ?? $actorUrl; @@ -510,76 +516,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->handleFeaturedCollection($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 handleFeaturedCollection(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 cd17948d5..3da788dc7 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -255,13 +255,16 @@ public function restore(User $user, Entry $entry): void $this->dispatcher->dispatch(new EntryRestoredEvent($entry, $user)); } - public function pin(Entry $entry): Entry + /** + * @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; $this->entityManager->flush(); - $this->dispatcher->dispatch(new EntryPinEvent($entry)); + $this->dispatcher->dispatch(new EntryPinEvent($entry, $actor)); 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/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); From 8e7f60e1badf31ff39b6f02a8d62f1b7c499313c Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 16 Jun 2024 01:15:00 +0200 Subject: [PATCH 2/9] Fix missing url params --- src/Factory/ActivityPub/AddRemoveFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Factory/ActivityPub/AddRemoveFactory.php b/src/Factory/ActivityPub/AddRemoveFactory.php index c01b696f7..1f77a351d 100644 --- a/src/Factory/ActivityPub/AddRemoveFactory.php +++ b/src/Factory/ActivityPub/AddRemoveFactory.php @@ -51,7 +51,7 @@ public function buildAddPinnedPost(User $actor, Entry $added): array 'ap_magazine_pinned', ['name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); $entryUrl = $added->apId ?? $this->urlGenerator->generate( - 'ap_entry', ['entry_id' => $added->getId()], UrlGeneratorInterface::ABSOLUTE_URL + 'ap_entry', ['entry_id' => $added->getId(), 'magazine_name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $entryUrl, $added->magazine, 'Add', $url); @@ -63,7 +63,7 @@ public function buildRemovePinnedPost(User $actor, Entry $removed): array 'ap_magazine_pinned', ['name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); $entryUrl = $removed->apId ?? $this->urlGenerator->generate( - 'ap_entry', ['entry_id' => $removed->getId()], UrlGeneratorInterface::ABSOLUTE_URL + 'ap_entry', ['entry_id' => $removed->getId(), 'magazine_name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); return $this->build($actor, $entryUrl, $removed->magazine, 'Remove', $url); From d773bac7b8b6f6c90121e448281f1295f17a5f46 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 16 Jun 2024 02:13:34 +0200 Subject: [PATCH 3/9] Implement incoming add/remove activities - implement incoming add/remove activities for the featured collection --- .../Magazine/MagazinePinnedController.php | 1 - .../ActivityPub/Inbox/AddHandler.php | 76 ++++++++++++++++++- .../ActivityPub/Inbox/RemoveHandler.php | 63 ++++++++++++++- src/Repository/MagazineRepository.php | 16 ++++ 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php b/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php index a694f66fe..e737a8db4 100644 --- a/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php +++ b/src/Controller/ActivityPub/Magazine/MagazinePinnedController.php @@ -9,7 +9,6 @@ use App\Factory\ActivityPub\EntryPageFactory; use App\Repository\EntryRepository; use App\Repository\TagLinkRepository; -use App\Service\ActivityPubManager; use JetBrains\PhpStorm\ArrayShape; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; diff --git a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php index b4b2b8ffc..63279a034 100644 --- a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php @@ -4,22 +4,39 @@ namespace App\MessageHandler\ActivityPub\Inbox; +use _PHPStan_5473b6701\Symfony\Component\Console\Exception\LogicException; use App\DTO\ModeratorDto; +use App\Entity\Entry; +use App\Entity\Magazine; +use App\Entity\User; use App\Message\ActivityPub\Inbox\AddMessage; +use App\Message\ActivityPub\Inbox\CreateMessage; +use App\Repository\ApActivityRepository; +use App\Repository\EntryRepository; use App\Repository\MagazineRepository; +use App\Service\ActivityPub\ApHttpClient; 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; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] class AddHandler { public function __construct( private readonly ActivityPubManager $activityPubManager, + private readonly ApHttpClient $apHttpClient, + private readonly ApActivityRepository $apActivityRepository, private readonly MagazineRepository $magazineRepository, private readonly MagazineManager $magazineManager, private readonly LoggerInterface $logger, + private readonly MessageBusInterface $bus, + private readonly EntryRepository $entryRepository, + private readonly EntryManager $entryManager, + private readonly SettingsManager $settingsManager, ) { } @@ -28,14 +45,27 @@ public function __invoke(AddMessage $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->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 +87,44 @@ 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) 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. 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 (!$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/RemoveHandler.php b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php index cd746626c..d74967f3e 100644 --- a/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php @@ -4,10 +4,18 @@ namespace App\MessageHandler\ActivityPub\Inbox; +use _PHPStan_5473b6701\Symfony\Component\Console\Exception\LogicException; +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 +23,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 +39,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 +89,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) 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. 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/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; + } } From c8fb494ccbf0d09033357749713d22e632f6007f Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 26 Jun 2024 09:46:57 +0200 Subject: [PATCH 4/9] Fix moderator federation - it got broken when expanding the `AddRemoveFactory` --- src/Factory/ActivityPub/AddRemoveFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Factory/ActivityPub/AddRemoveFactory.php b/src/Factory/ActivityPub/AddRemoveFactory.php index 1f77a351d..af54078ab 100644 --- a/src/Factory/ActivityPub/AddRemoveFactory.php +++ b/src/Factory/ActivityPub/AddRemoveFactory.php @@ -26,7 +26,7 @@ public function buildAddModerator(User $actor, User $added, Magazine $magazine): $url = $magazine->apAttributedToUrl ?? $this->urlGenerator->generate( 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); - $addedUserUrl = $targetUser->apId ?? $this->urlGenerator->generate( + $addedUserUrl = null !== $added->apId ? $added->apPublicUrl : $this->urlGenerator->generate( 'ap_user', ['username' => $added->username], UrlGeneratorInterface::ABSOLUTE_URL ); @@ -38,7 +38,7 @@ public function buildRemoveModerator(User $actor, User $removed, Magazine $magaz $url = $magazine->apAttributedToUrl ?? $this->urlGenerator->generate( 'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); - $removedUserUrl = $targetUser->apId ?? $this->urlGenerator->generate( + $removedUserUrl = null !== $removed->apId ? $removed->apPublicUrl : $this->urlGenerator->generate( 'ap_user', ['username' => $removed->username], UrlGeneratorInterface::ABSOLUTE_URL ); @@ -50,7 +50,7 @@ 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( + $entryUrl = null !== $added->apId ?? $this->urlGenerator->generate( 'ap_entry', ['entry_id' => $added->getId(), 'magazine_name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); From b4b70f6859b1a418edc8178a7ed64f1d76635da7 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 26 Jun 2024 10:57:20 +0200 Subject: [PATCH 5/9] Fix some fields not being urls --- src/EventSubscriber/Entry/EntryPinSubscriber.php | 3 +++ src/Factory/ActivityPub/AddRemoveFactory.php | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/EventSubscriber/Entry/EntryPinSubscriber.php b/src/EventSubscriber/Entry/EntryPinSubscriber.php index 6db7f3c0c..a0bab35e3 100644 --- a/src/EventSubscriber/Entry/EntryPinSubscriber.php +++ b/src/EventSubscriber/Entry/EntryPinSubscriber.php @@ -6,6 +6,7 @@ use App\Event\Entry\EntryPinEvent; use App\Message\ActivityPub\Outbox\EntryPinMessage; +use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Messenger\MessageBusInterface; @@ -13,6 +14,7 @@ class EntryPinSubscriber implements EventSubscriberInterface { public function __construct( private readonly MessageBusInterface $bus, + private readonly LoggerInterface $logger, ) { } @@ -26,6 +28,7 @@ public static function getSubscribedEvents(): array public function onEntryPin(EntryPinEvent $event): void { if (null === $event->entry->magazine->apId || ($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())); } } diff --git a/src/Factory/ActivityPub/AddRemoveFactory.php b/src/Factory/ActivityPub/AddRemoveFactory.php index af54078ab..663e2f2bd 100644 --- a/src/Factory/ActivityPub/AddRemoveFactory.php +++ b/src/Factory/ActivityPub/AddRemoveFactory.php @@ -50,7 +50,7 @@ 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 = null !== $added->apId ?? $this->urlGenerator->generate( + $entryUrl = $added->apId ?? $this->urlGenerator->generate( 'ap_entry', ['entry_id' => $added->getId(), 'magazine_name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ); @@ -89,19 +89,19 @@ private function build(User $actor, string $targetObjectUrl, Magazine $magazine, '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' => $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' => $collectionUrl, - 'audience' => $magazine->apId ?? $this->urlGenerator->generate( + 'audience' => null !== $magazine->apId ? $magazine->apPublicUrl : $this->urlGenerator->generate( 'ap_magazine', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL ), ]; From bfe682e18ca2429185b931323c6abec49dbc84bb Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 26 Jun 2024 11:42:46 +0200 Subject: [PATCH 6/9] Implement announce - When a remote moderator pins or unpins an entry this will now be announced to all subscribers of the magazine --- .../Entry/EntryPinSubscriber.php | 18 +++++++- .../Outbox/GenericAnnounceMessage.php | 14 +++++++ .../ActivityPub/Inbox/AddHandler.php | 5 +-- .../ActivityPub/Inbox/RemoveHandler.php | 5 +-- .../Outbox/EntryPinMessageHandler.php | 20 +++++---- .../Outbox/GenericAnnounceHandler.php | 41 +++++++++++++++++++ 6 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 src/Message/ActivityPub/Outbox/GenericAnnounceMessage.php create mode 100644 src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php diff --git a/src/EventSubscriber/Entry/EntryPinSubscriber.php b/src/EventSubscriber/Entry/EntryPinSubscriber.php index a0bab35e3..c65e1b754 100644 --- a/src/EventSubscriber/Entry/EntryPinSubscriber.php +++ b/src/EventSubscriber/Entry/EntryPinSubscriber.php @@ -5,7 +5,9 @@ namespace App\EventSubscriber\Entry; use App\Event\Entry\EntryPinEvent; +use App\Factory\ActivityPub\AddRemoveFactory; use App\Message\ActivityPub\Outbox\EntryPinMessage; +use App\Message\ActivityPub\Outbox\GenericAnnounceMessage; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Messenger\MessageBusInterface; @@ -15,6 +17,7 @@ class EntryPinSubscriber implements EventSubscriberInterface public function __construct( private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger, + private readonly AddRemoveFactory $addRemoveFactory, ) { } @@ -27,9 +30,22 @@ public static function getSubscribedEvents(): array public function onEntryPin(EntryPinEvent $event): void { - if (null === $event->entry->magazine->apId || ($event->actor && null === $event->actor->apId && $event->entry->magazine->userIsModerator($event->actor))) { + 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/Message/ActivityPub/Outbox/GenericAnnounceMessage.php b/src/Message/ActivityPub/Outbox/GenericAnnounceMessage.php new file mode 100644 index 000000000..2c57b8a3e --- /dev/null +++ b/src/Message/ActivityPub/Outbox/GenericAnnounceMessage.php @@ -0,0 +1,14 @@ +userIsModerator($actor) and !$targetMag->hasSameHostAsUser($actor)) { + 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"); } @@ -100,7 +99,7 @@ private function handlePinnedAdd(Magazine $targetMag, User $actor, mixed $object } elseif (\is_array($object)) { $apId = $object['id']; } else { - throw new LogicException('the added object is neither a string or an array'); + throw new \LogicException('the added object is neither a string or an array'); } if ($this->settingsManager->isLocalUrl($apId)) { diff --git a/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php index d74967f3e..51e8d9b8e 100644 --- a/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php @@ -4,7 +4,6 @@ namespace App\MessageHandler\ActivityPub\Inbox; -use _PHPStan_5473b6701\Symfony\Component\Console\Exception\LogicException; use App\Entity\Entry; use App\Entity\Magazine; use App\Entity\User; @@ -92,7 +91,7 @@ public function handleModeratorRemove($object1, Magazine $targetMag, Magazine|Us private function handlePinnedRemove(mixed $object, Magazine $targetMag, User $actor): void { - if (!$targetMag->userIsModerator($actor) and !$targetMag->hasSameHostAsUser($actor)) { + 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"); } @@ -102,7 +101,7 @@ private function handlePinnedRemove(mixed $object, Magazine $targetMag, User $ac } elseif (\is_array($object)) { $apId = $object['id']; } else { - throw new LogicException('the added object is neither a string or an array'); + throw new \LogicException('the added object is neither a string or an array'); } if ($this->settingsManager->isLocalUrl($apId)) { $pair = $this->apActivityRepository->findLocalByApId($apId); diff --git a/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php index 4548fcd85..2d1d1aefd 100644 --- a/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php @@ -5,14 +5,14 @@ namespace App\MessageHandler\ActivityPub\Outbox; use App\Factory\ActivityPub\AddRemoveFactory; -use App\Message\ActivityPub\Outbox\DeliverMessage; use App\Message\ActivityPub\Outbox\EntryPinMessage; use App\Repository\EntryRepository; use App\Repository\MagazineRepository; use App\Repository\UserRepository; +use App\Service\DeliverManager; use App\Service\SettingsManager; +use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] class EntryPinMessageHandler @@ -23,7 +23,8 @@ public function __construct( private readonly UserRepository $userRepository, private readonly AddRemoveFactory $addRemoveFactory, private readonly MagazineRepository $magazineRepository, - private readonly MessageBusInterface $bus, + private readonly DeliverManager $deliverManager, + private readonly LoggerInterface $logger, ) { } @@ -34,6 +35,13 @@ public function __invoke(EntryPinMessage $message): void } $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 { @@ -46,10 +54,6 @@ public function __invoke(EntryPinMessage $message): void $audience = $this->magazineRepository->findAudience($entry->magazine); } - foreach ($audience as $inboxUrl) { - if (!$this->settingsManager->isBannedInstance($inboxUrl)) { - $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity)); - } - } + $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); + } +} From 4e48a29d28eef3a1a31dbd5b4a28c175aaed2104 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 30 Jun 2024 00:13:36 +0200 Subject: [PATCH 7/9] Fix sticky on create, add modlog - add a modlog for pinning and unpinning an entry - actually implement the sticky on create --- src/Entity/MagazineLog.php | 2 + src/Entity/MagazineLogEntryPinned.php | 46 +++++++++++++++++++ src/Entity/MagazineLogEntryUnpinned.php | 46 +++++++++++++++++++ .../ActivityPub/Inbox/CreateHandler.php | 6 ++- src/Service/ActivityPub/Note.php | 8 ++-- src/Service/ActivityPub/Page.php | 4 +- src/Service/ActivityPubManager.php | 4 +- src/Service/EntryManager.php | 15 +++++- src/Service/PostManager.php | 6 ++- templates/modlog/_blocks.html.twig | 22 +++++++++ translations/messages.en.yaml | 2 + 11 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 src/Entity/MagazineLogEntryPinned.php create mode 100644 src/Entity/MagazineLogEntryUnpinned.php 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/MessageHandler/ActivityPub/Inbox/CreateHandler.php b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php index bca000ea8..9fc14f75f 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, @@ -40,6 +41,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}', [$message->payload['type'], $message->payload]); $entryTypes = ['Page', 'Article', 'Video']; $postTypes = ['Question', 'Note']; @@ -70,7 +72,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) { @@ -87,7 +89,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/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 70d4429a7..2f851ea80 100644 --- a/src/Service/ActivityPubManager.php +++ b/src/Service/ActivityPubManager.php @@ -522,7 +522,7 @@ public function updateMagazine(string $actorUrl): ?Magazine } if (null !== $magazine->apFeaturedUrl) { - $this->handleFeaturedCollection($actorUrl, $magazine); + $this->handleMagazineFeaturedCollection($actorUrl, $magazine); } $this->entityManager->flush(); @@ -605,7 +605,7 @@ private function handleModeratorCollection(string $actorUrl, Magazine $magazine) /** * @throws InvalidArgumentException */ - private function handleFeaturedCollection(string $actorUrl, Magazine $magazine): void + private function handleMagazineFeaturedCollection(string $actorUrl, Magazine $magazine): void { try { $this->logger->debug('fetching featured posts of remote magazine "{magUrl}"', ['magUrl' => $actorUrl]); diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index acc07bb96..361b43f45 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; @@ -65,7 +67,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 +125,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; } @@ -274,6 +280,13 @@ 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, $actor)); diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index ee5824b0a..acecd1751 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -61,7 +61,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 +112,10 @@ public function create(PostDto $dto, User $user, $rateLimit = true): Post $this->dispatcher->dispatch(new PostCreatedEvent($post)); + if ($stickyIt) { + $this->pin($post); + } + 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/translations/messages.en.yaml b/translations/messages.en.yaml index 17eb07262..6257dfd35 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -829,4 +829,6 @@ cake_day: Cake day someone: Someone 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 From aef606c34a029a1fba669893d879ab0db92540ad Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Mon, 1 Jul 2024 09:36:22 +0200 Subject: [PATCH 8/9] Invalidate cache when an entry or post is pinned, so the next actor update will not undo the pin --- src/MessageHandler/ActivityPub/Inbox/AddHandler.php | 3 +++ src/Service/ActivityPub/ApHttpClient.php | 5 +++++ src/Service/EntryManager.php | 6 ++++++ src/Service/PostManager.php | 6 ++++++ 4 files changed, 20 insertions(+) diff --git a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php index c1c74f9a0..de35731a0 100644 --- a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php @@ -114,6 +114,9 @@ private function handlePinnedAdd(Magazine $targetMag, User $actor, mixed $object } 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); 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/EntryManager.php b/src/Service/EntryManager.php index 361b43f45..70d7e4787 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -24,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; @@ -57,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 ) { } @@ -291,6 +293,10 @@ public function pin(Entry $entry, ?User $actor): 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/PostManager.php b/src/Service/PostManager.php index acecd1751..6a644bd32 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 ) { } @@ -234,6 +236,10 @@ public function pin(Post $post): Post $this->entityManager->flush(); + if (null !== $post->magazine->apFeaturedUrl) { + $this->apHttpClient->invalidateCollectionObjectCache($post->magazine->apFeaturedUrl); + } + return $post; } From 3f45460795a4fb745f15be1066847283ac175a03 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Tue, 2 Jul 2024 11:52:49 +0200 Subject: [PATCH 9/9] Add method description to `pin` --- src/Service/EntryManager.php | 2 ++ src/Service/PostManager.php | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index 70d7e4787..0e0721f8a 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -276,6 +276,8 @@ public function restore(User $user, Entry $entry): void } /** + * 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 diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index 6a644bd32..06f25da62 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -230,6 +230,9 @@ 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;