diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index ad9883dcf3..03c1035c66 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -91,6 +91,7 @@ private function findObjectsByAp(string $urlOrHandle): array foreach ($result['errors'] as $error) { /** @var \Throwable $error */ + $this->logger->warning('SearchController: exception occurred while searching with ActivityPub: {e}', ['e' => $error]); $this->addFlash('error', $error->getMessage()); } diff --git a/src/Markdown/CommonMark/MentionLinkParser.php b/src/Markdown/CommonMark/MentionLinkParser.php index 1136f85bce..2787b6af93 100644 --- a/src/Markdown/CommonMark/MentionLinkParser.php +++ b/src/Markdown/CommonMark/MentionLinkParser.php @@ -56,7 +56,7 @@ public function parse(InlineParserContext $ctx): bool $data->apPublicUrl, '@'.$username, '@'.$data->apId, - '@'.$data->apId, + $data->username, MentionType::RemoteUser, ) ); diff --git a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php index 73fa51d5ed..c275de90a2 100644 --- a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php @@ -26,6 +26,7 @@ use App\Service\ActivityPub\Note; use App\Service\ActivityPub\Page; use App\Service\SettingsManager; +use App\Utils\JsonldUtils; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\KernelInterface; @@ -119,7 +120,7 @@ private function retrieveObject(string $apUrl): Entry|EntryComment|Post|PostComm } if (\array_key_exists('inReplyTo', $object) && null !== $object['inReplyTo']) { - $parentUrl = \is_string($object['inReplyTo']) ? $object['inReplyTo'] : $object['inReplyTo']['id']; + $parentUrl = JsonldUtils::getApId($object['inReplyTo']); $meta = $this->repository->findByObjectId($parentUrl); if (!$meta) { $this->retrieveObject($parentUrl); diff --git a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php index 7d20358163..495976d010 100644 --- a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php @@ -29,6 +29,7 @@ use App\Service\ActivityPub\Note; use App\Service\ActivityPub\Page; use App\Service\MessageManager; +use App\Utils\JsonldUtils; use App\Utils\UrlUtils; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; @@ -134,7 +135,7 @@ public function doWork(MessageInterface $message): void private function handleChain(array $object, bool $stickyIt, ?array $fullCreatePayload): void { if (isset($object['inReplyTo']) && $object['inReplyTo']) { - $existed = $this->repository->findByObjectId($object['inReplyTo']); + $existed = $this->repository->findByObjectId(JsonldUtils::getApId($object['inReplyTo'])); if (!$existed) { $this->bus->dispatch(new ChainActivityMessage([$object])); diff --git a/src/Service/ActivityPub/ApHttpClient.php b/src/Service/ActivityPub/ApHttpClient.php index 0e60228f12..8ba61ebf3c 100644 --- a/src/Service/ActivityPub/ApHttpClient.php +++ b/src/Service/ActivityPub/ApHttpClient.php @@ -46,8 +46,13 @@ enum ApRequestType class ApHttpClient implements ApHttpClientInterface { - public const TIMEOUT = 8; - public const MAX_DURATION = 15; + public const int TIMEOUT = 8; + public const int MAX_DURATION = 15; + + /** + * useful when running a local dev instance as it can be treated as invalid. + */ + private const bool SKIP_HTTP_SIGNATURE = false; public function __construct( private readonly string $kbinDomain, @@ -699,6 +704,11 @@ private function getInstanceHeaders(string $url, ?array $body = null, string $me $headers['User-Agent'] = $this->projectInfo->getUserAgent(); $headers = array_merge($headers, $this->getFetchAcceptHeaders($requestType)); + if (self::SKIP_HTTP_SIGNATURE) { + unset($headers['Signature']); + unset($headers['Date']); + } + return $headers; } diff --git a/src/Service/ActivityPub/ApObjectExtractor.php b/src/Service/ActivityPub/ApObjectExtractor.php index 93f27d0baa..59c0369be8 100644 --- a/src/Service/ActivityPub/ApObjectExtractor.php +++ b/src/Service/ActivityPub/ApObjectExtractor.php @@ -4,6 +4,7 @@ namespace App\Service\ActivityPub; +use App\DTO\ImageDto; use App\Service\ActivityPubManager; class ApObjectExtractor @@ -42,14 +43,14 @@ public function getMarkdownBody(array $object): ?string return ''; } - public function getExternalMediaBody(array $object): ?string + public function getExternalMediaBody(array $object, ?ImageDto $consumedImage): ?string { $body = null; if (isset($object['attachment'])) { $attachments = $object['attachment']; - if ($images = $this->activityPubManager->handleExternalImages($attachments)) { + if ($images = $this->activityPubManager->handleExternalImages($attachments, $consumedImage)) { $body .= "\n\n".implode( " \n", array_map( diff --git a/src/Service/ActivityPub/Note.php b/src/Service/ActivityPub/Note.php index 63c573ab4e..5c13e99629 100644 --- a/src/Service/ActivityPub/Note.php +++ b/src/Service/ActivityPub/Note.php @@ -27,6 +27,7 @@ use App\Service\PostCommentManager; use App\Service\PostManager; use App\Service\SettingsManager; +use App\Utils\JsonldUtils; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; @@ -79,6 +80,7 @@ public function create(array $object, ?array $root = null, bool $stickyIt = fals if (isset($object['inReplyTo']) && $replyTo = $object['inReplyTo']) { // Create post or entry comment + $replyTo = JsonldUtils::getApId($replyTo); $parentObjectId = $this->repository->findByObjectId($replyTo); $parent = $this->entityManager->getRepository($parentObjectId['type'])->find((int) $parentObjectId['id']); @@ -140,7 +142,7 @@ private function createEntryComment(array $object, ActivityPubActivityInterface throw new UserDeletedException(); } $dto->body = $this->objectExtractor->getMarkdownBody($object); - if ($media = $this->objectExtractor->getExternalMediaBody($object)) { + if ($media = $this->objectExtractor->getExternalMediaBody($object, $dto->image)) { $dto->body .= $media; } @@ -196,7 +198,7 @@ private function createPost(array $object, bool $stickyIt = false): Post } $dto->body = $this->objectExtractor->getMarkdownBody($object); - if ($media = $this->objectExtractor->getExternalMediaBody($object)) { + if ($media = $this->objectExtractor->getExternalMediaBody($object, $dto->image)) { $dto->body .= $media; } @@ -261,7 +263,7 @@ private function createPostComment(array $object, ActivityPubActivityInterface $ throw new UserDeletedException(); } $dto->body = $this->objectExtractor->getMarkdownBody($object); - if ($media = $this->objectExtractor->getExternalMediaBody($object)) { + if ($media = $this->objectExtractor->getExternalMediaBody($object, $dto->image)) { $dto->body .= $media; } diff --git a/src/Service/ActivityPubManager.php b/src/Service/ActivityPubManager.php index f982dda27f..fc16dd719b 100644 --- a/src/Service/ActivityPubManager.php +++ b/src/Service/ActivityPubManager.php @@ -507,21 +507,31 @@ private function updateUser(string $actorUrl): ?User public function handleImages(array|string $attachment): ?Image { - if (\is_string($attachment) && filter_var($attachment, FILTER_VALIDATE_URL)) { + if (\is_string($attachment)) { + if (!filter_var($attachment, FILTER_VALIDATE_URL)) { + return null; + } + $path = parse_url($attachment, PHP_URL_PATH); - $query = parse_url($attachment, PHP_URL_QUERY); + $query = parse_url($attachment, PHP_URL_QUERY) ?? ''; $attachment = [ [ 'url' => $attachment, 'type' => 'Image', ], ]; + + if (null === $path) { + return null; + } + if (str_contains($path, 'jpg') || str_contains($path, 'jpeg') || str_contains($query, 'jpg') || str_contains($query, 'jpeg')) { $attachment[0]['mediaType'] = 'image/jpeg'; } elseif (str_contains($path, 'png') || str_contains($query, 'png')) { $attachment[0]['mediaType'] = 'image/png'; } } + $images = array_filter( $attachment, fn ($val) => $this->isImageAttachment($val) @@ -531,9 +541,9 @@ public function handleImages(array|string $attachment): ?Image try { $imageObject = $images[array_key_first($images)]; if (isset($imageObject['height'])) { - // determine the highest resolution image + // determine the highest resolution image for the same image (equality is ducktyped by comparing the alt text) foreach ($images as $i) { - if (isset($i['height']) && $i['height'] ?? 0 > $imageObject['height'] ?? 0) { + if (isset($i['height']) && $i['height'] > ($imageObject['height'] ?? 0) && ($i['name'] ?? '') === ($imageObject['name'] ?? '')) { $imageObject = $i; } } @@ -965,15 +975,13 @@ public function handleVideos(array $attachment): ?VideoDto return null; } - public function handleExternalImages(array $attachment): ?array + public function handleExternalImages(array $attachment, ?\App\DTO\ImageDto $consumedImage): ?array { $images = array_filter( $attachment, - fn ($val) => $this->isImageAttachment($val) + fn ($val) => $this->isImageAttachment($val) && !$this->describeSameImage($val, $consumedImage) ); - array_shift($images); - if (\count($images)) { return array_map(fn ($val) => (new ImageDto())->create( $val['url'], @@ -989,7 +997,7 @@ public function handleExternalVideos(array $attachment): ?array { $videos = array_filter( $attachment, - fn ($val) => \in_array($val['type'], ['Document', 'Video']) && VideoManager::isVideoUrl($val['url']) + fn ($val) => \in_array($val['type'], ['Document', 'Video']) && (VideoManager::isSupportedVideoMimeType($val['mediaType']) || VideoManager::isVideoUrl($val['url'])) ); if (\count($videos)) { @@ -1080,7 +1088,7 @@ public static function getReceivers(array $object): array private function isImageAttachment(array $object): bool { // attachment object has acceptable object type - if (!\in_array($object['type'], ['Document', 'Image'])) { + if (!\in_array($object['type'], ['Document', 'Image']) || !isset($object['url']) || !\is_string($object['url'])) { return false; } @@ -1091,6 +1099,21 @@ private function isImageAttachment(array $object): bool || ImageManager::isImageUrl($object['url']); } + private function describeSameImage(array $imgA, ?\App\DTO\ImageDto $imgB): bool + { + if (null === $imgB) { + return false; + } + + if ($imgA['url'] === $imgB->sourceUrl) { + return true; + } + + $altTextA = $imgA['name'] ?? null; + $altTextB = $imgB->altText ?? null; + return null !== $altTextA && '' !== $altTextA && $altTextA === $altTextB; + } + /** * @param string|array $apObject the object that should be like, so a post of any kind in its AP array representation or a URL * @param array $fullPayload the full message payload, only used to log it @@ -1225,7 +1248,7 @@ public function getActorFromAttributedTo(string|array|null $attributedTo, bool $ } elseif (\is_array($attributedTo)) { $actors = array_filter($attributedTo, fn ($item) => \is_string($item) || (\is_array($item) && !empty($item['type']) && (!$filterForPerson || 'Person' === $item['type']))); - return array_map(fn ($item) => $item['id'], $actors); + return array_map(fn ($item) => \is_string($item) ? $item : $item['id'], $actors); } return []; diff --git a/src/Service/VideoManager.php b/src/Service/VideoManager.php index 005947425d..3e1a32c27a 100644 --- a/src/Service/VideoManager.php +++ b/src/Service/VideoManager.php @@ -6,7 +6,8 @@ class VideoManager { - public const VIDEO_MIMETYPES = ['video/mp4', 'video/webm']; + public const array VIDEO_MIMETYPES = ['video/mp4', 'video/webm', 'video/quicktime', 'video/ogg']; + public const array VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'mov', 'ogv', 'ogg']; public static function isVideoUrl(string $url): bool { @@ -17,9 +18,7 @@ public static function isVideoUrl(string $url): bool $path = (string) parse_url($url, PHP_URL_PATH); $urlExt = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - $types = array_map(fn ($type) => str_replace('video/', '', $type), self::VIDEO_MIMETYPES); - - return \in_array($urlExt, $types, false); + return \in_array($urlExt, self::VIDEO_FILE_EXTENSIONS, false); } public static function isSupportedVideoMimeType(?string $mimeType): bool diff --git a/src/Utils/JsonldUtils.php b/src/Utils/JsonldUtils.php index 54364f79fe..c19915ccc5 100644 --- a/src/Utils/JsonldUtils.php +++ b/src/Utils/JsonldUtils.php @@ -17,4 +17,19 @@ public static function getArrayValue(array $object, string $key): array return [$object[$key]]; } + + public static function getApId(string|array $value): string + { + if (\is_string($value)) { + return $value; + } elseif (\is_array($value)) { + $value = $value['id'] ?? null; + if (!\is_string($value)) { + throw new \LogicException('JsonldUtils::getApId(): value is array but did not contain valid `id` element'); + } + return $value; + } else { + throw new \LogicException('JsonldUtils::getApId(): value is neither a string nor an array'); + } + } } diff --git a/tests/Unit/Service/ActivityPub/ActivityPubManagerTest.php b/tests/Unit/Service/ActivityPub/ActivityPubManagerTest.php new file mode 100644 index 0000000000..fbe8d2a2f1 --- /dev/null +++ b/tests/Unit/Service/ActivityPub/ActivityPubManagerTest.php @@ -0,0 +1,195 @@ + 'Image', + 'url' => 'https://example.com/image1.jpg', + ], + ]; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->with('https://example.com/image1.jpg')->willReturn( + new Image('success.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNotNull($img); + self::assertSame('success.jpg', $img->fileName); + } + + public function testHandleImagesFindsBestImageOfMultiAscResolution() + { + $attachment = [ + [ + 'type' => 'Image', + 'name' => 'A', + 'url' => 'https://example.com/image-a-1.jpg', + 'height' => 100, + 'width' => 101, + ], + [ + 'type' => 'Image', + 'name' => 'B', + 'url' => 'https://example.com/image-b-1.jpg', + 'height' => 200, + 'width' => 201, + ], + [ + 'type' => 'Image', + 'name' => 'A', + 'url' => 'https://example.com/image-a-2.jpg', + 'height' => 110, + 'width' => 111, + ], + ]; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->with('https://example.com/image-a-2.jpg')->willReturn( + new Image('success.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNotNull($img); + self::assertSame('success.jpg', $img->fileName); + } + + public function testHandleImagesFindsBestImageOfMultiDescResolution() + { + $attachment = [ + [ + 'type' => 'Image', + 'name' => 'A', + 'url' => 'https://example.com/image-a-1.jpg', + 'height' => 110, + 'width' => 111, + ], + [ + 'type' => 'Image', + 'name' => 'B', + 'url' => 'https://example.com/image-b-1.jpg', + 'height' => 200, + 'width' => 201, + ], + [ + 'type' => 'Image', + 'name' => 'A', + 'url' => 'https://example.com/image-a-2.jpg', + 'height' => 100, + 'width' => 101, + ], + ]; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->with('https://example.com/image-a-1.jpg')->willReturn( + new Image('success.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNotNull($img); + self::assertSame('success.jpg', $img->fileName); + } + + public function testHandleImagesIgnoresNonImage() + { + $attachment = [ + [ + 'type' => 'Other', + 'url' => 'https://example.com/document.pdf', + ], + ]; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->willReturn( + new Image('success.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNull($img); + } + + public function testHandleImagesHandlesValidString() + { + $attachment = 'https://example.com/image.png'; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->with('https://example.com/image.png')->willReturn( + new Image('success.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNotNull($img); + self::assertSame('success.jpg', $img->fileName); + } + + public function testHandleImagesHandlesInvalidString() + { + $attachment = 'image.png'; + + $imageManager = $this->createStub(ImageManager::class); + $imageRepository = $this->createStub(ImageRepository::class); + $imageManager->method('download')->willReturnArgument(0); + $imageRepository->method('findOrCreateFromPath')->with('image.png')->willReturn( + new Image('dummy.jpg', '', '0000000000000000000000000000000000000000000000000000000000000000', 1, 1, '') + ); + + $mng = $this->initiateMocked(ActivityPubManager::class, ['imageManager' => $imageManager, 'imageRepository' => $imageRepository]); + $img = $mng->handleImages($attachment); + + self::assertNull($img); + } + + /** + * @template T + * + * @param class-string $class + * @param array $mocks + * + * @return T + */ + private function initiateMocked(string $class, array $mocks = []): object + { + $reflect = new \ReflectionClass($class); + $constructor = $reflect->getConstructor(); + + $consParams = []; + foreach ($constructor->getParameters() as $param) { + $mock = $mocks[$param->getName()] ?? $this->createMock($param->getType()->getName()); + $consParams[] = $mock; + } + + return $reflect->newInstanceArgs($consParams); + } +}