Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 78 additions & 56 deletions src/Controller/Api/Search/SearchRetrieveApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@

namespace App\Controller\Api\Search;

use App\ActivityPub\ActorHandle;
use App\Controller\Api\BaseApi;
use App\Controller\Traits\PrivateContentTrait;
use App\Entity\Contracts\ContentInterface;
use App\Factory\MagazineFactory;
use App\Factory\UserFactory;
use App\DTO\SearchResponseDto;
use App\Entity\Entry;
use App\Entity\EntryComment;
use App\Entity\Magazine;
use App\Entity\Post;
use App\Entity\PostComment;
use App\Entity\User;
use App\Repository\SearchRepository;
use App\Schema\ContentSchema;
use App\Schema\PaginationSchema;
use App\Schema\SearchActorSchema;
use App\Service\SearchManager;
use App\Service\SettingsManager;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
Expand All @@ -27,34 +29,23 @@ class SearchRetrieveApi extends BaseApi

#[OA\Response(
response: 200,
description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. Actors and objects are not paginated',
description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. AP-Objects are not paginated.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(
property: 'items',
type: 'array',
items: new OA\Items(
ref: new Model(type: ContentSchema::class)
)
items: new OA\Items(ref: new Model(type: SearchResponseDto::class))
),
new OA\Property(
property: 'pagination',
ref: new Model(type: PaginationSchema::class)
),
new OA\Property(
property: 'apActors',
property: 'apResults',
type: 'array',
items: new OA\Items(
ref: new Model(type: SearchActorSchema::class)
)
),
new OA\Property(
property: 'apObjects',
type: 'array',
items: new OA\Items(
ref: new Model(type: ContentSchema::class)
)
items: new OA\Items(ref: new Model(type: SearchResponseDto::class))
),
]
),
Expand Down Expand Up @@ -127,15 +118,13 @@ class SearchRetrieveApi extends BaseApi
#[OA\Tag(name: 'search')]
public function __invoke(
SearchManager $manager,
UserFactory $userFactory,
MagazineFactory $magazineFactory,
SettingsManager $settingsManager,
RateLimiterFactoryInterface $apiReadLimiter,
RateLimiterFactoryInterface $anonymousApiReadLimiter,
): JsonResponse {
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);

$request = $this->request->getCurrentRequest();
/** @var string $q */
$q = $request->get('q');
if (null === $q) {
throw new BadRequestHttpException();
Expand All @@ -152,49 +141,82 @@ public function __invoke(
throw new BadRequestHttpException();
}

/** @var ?SearchResponseDto $searchResults */
$searchResults = [];
$items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type);
$dtos = [];
foreach ($items->getCurrentPageResults() as $value) {
\assert($value instanceof ContentInterface);
array_push($dtos, $this->serializeContentInterface($value));
foreach ($items->getCurrentPageResults() as $item) {
$searchResults[] = $this->serializeItem($item);
}

$response = $this->serializePaginated($dtos, $items);

$response['apActors'] = [];
$response['apObjects'] = [];
$actors = [];
$objects = [];
if (!$settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser()) {
$actors = $manager->findActivityPubActorsByUsername($q);
$objects = $manager->findActivityPubObjectsByURL($q);
}
/** @var ?SearchResponseDto $apResults */
$apResults = [];
if ($this->federatedSearchAllowed()) {
if ($handle = ActorHandle::parse($q)) {
$actors = $manager->findActivityPubActorsByUsername($handle);
foreach ($actors as $actor) {
$apResults[] = $this->serializeItem($actor['object']);
}
} else {
$objects = $manager->findActivityPubObjectsByURL($q);
foreach ($objects['errors'] as $error) {
/** @var \Exception $error */
$this->logger->warning(
'Exception while resolving URL {url}: {type}: {msg}',
[
'url' => $q,
'type' => \get_class($error),
'msg' => $error->getMessage(),
]
);
}

foreach ($actors as $actor) {
switch ($actor['type']) {
case 'user':
$response['apActors'][] = [
'type' => 'user',
'object' => $this->serializeUser($userFactory->createDto($actor['object'])),
];
break;
case 'magazine':
$response['apActors'][] = [
'type' => 'magazine',
'object' => $this->serializeMagazine($magazineFactory->createDto($actor['object'])),
];
break;
foreach ($objects['results'] as $object) {
$apResults[] = $this->serializeItem($object['object']);
}
}
}

foreach ($objects as $object) {
\assert($object instanceof ContentInterface);
$response['apObjects'][] = $this->serializeContentInterface($object);
}
$response = $this->serializePaginated($searchResults, $items);
$response['apResults'] = $apResults;

return new JsonResponse(
$response,
headers: $headers
);
}

private function federatedSearchAllowed(): bool
{
return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN')
|| $this->getUser();
}

private function serializeItem(object $item): ?SearchResponseDto
{
if ($item instanceof Entry) {
$this->handlePrivateContent($item);

return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
} elseif ($item instanceof Post) {
$this->handlePrivateContent($item);

return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
} elseif ($item instanceof EntryComment) {
$this->handlePrivateContent($item);

return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
} elseif ($item instanceof PostComment) {
$this->handlePrivateContent($item);

return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
} elseif ($item instanceof Magazine) {
return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item)));
} elseif ($item instanceof User) {
return new SearchResponseDto(user: $this->serializeUser($this->userFactory->createDto($item)));
} else {
$this->logger->error('Unexpected result type: '.\get_class($item));

return null;
}
}
}
107 changes: 6 additions & 101 deletions src/Controller/SearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,20 @@

use App\ActivityPub\ActorHandle;
use App\DTO\SearchDto;
use App\Entity\Magazine;
use App\Entity\User;
use App\Form\SearchType;
use App\Message\ActivityPub\Inbox\CreateMessage;
use App\Service\ActivityPub\ApHttpClientInterface;
use App\Service\ActivityPubManager;
use App\Service\SearchManager;
use App\Service\SettingsManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;

class SearchController extends AbstractController
{
public function __construct(
private readonly SearchManager $manager,
private readonly ActivityPubManager $activityPubManager,
private readonly ApHttpClientInterface $apHttpClient,
private readonly SettingsManager $settingsManager,
private readonly LoggerInterface $logger,
private readonly MessageBusInterface $bus,
) {
}

Expand All @@ -52,7 +42,7 @@ public function __invoke(Request $request): Response
if (str_contains($query, '@') && $this->federatedSearchAllowed()) {
if ($handle = ActorHandle::parse($query)) {
$this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]);
$objects = $this->lookupHandle($handle);
$objects = $this->manager->findActivityPubActorsByUsername($handle);
} else {
$this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]);
}
Expand Down Expand Up @@ -108,100 +98,15 @@ private function federatedSearchAllowed(): bool
|| $this->getUser();
}

private function lookupHandle(ActorHandle $handle): array
{
$objects = [];
$name = $handle->plainHandle();

try {
$webfinger = $this->activityPubManager->webfinger($name);
foreach ($webfinger->getProfileIds() as $profileId) {
$this->logger->debug('Found "{profileId}" at "{name}"', ['profileId' => $profileId, 'name' => $name]);

// if actor object exists or successfully created
$object = $this->activityPubManager->findActorOrCreate($profileId);
if (!empty($object)) {
if ($object instanceof Magazine) {
$type = 'magazine';
} elseif ($object instanceof User && '!' !== $handle->prefix) {
$type = 'user';
}

$objects[] = [
'type' => $type,
'object' => $object,
];
}
}
} catch (\Exception $e) {
$this->logger->warning(
'an error occurred during webfinger lookup of "{handle}": {exceptionClass}: {message}',
[
'handle' => $name,
'exceptionClass' => \get_class($e),
'message' => $e->getMessage(),
]
);
}

return $objects;
}

private function findObjectsByApUrl(string $url): array
{
$objects = $this->manager->findByApId($url);
if (0 === \sizeof($objects)) {
// the url could resolve to a different id.
try {
$body = $this->apHttpClient->getActivityObject($url);
$apId = $body['id'];
$objects = $this->manager->findByApId($apId);
} catch (\Exception $e) {
$body = null;
$apId = $url;
$this->addFlash('error', $e->getMessage());
}

if (0 === \sizeof($objects) && null !== $body) {
// maybe it is an entry, post, etc.
try {
// process the message in the sync transport, so that the created content is directly visible
$this->bus->dispatch(new CreateMessage($body), [new TransportNamesStamp('sync')]);
$objects = $this->manager->findByApId($apId);
} catch (\Exception $e) {
$this->addFlash('error', $e->getMessage());
}
}
$result = $this->manager->findActivityPubObjectsByURL($url);

if (0 === \sizeof($objects)) {
// maybe it is a magazine or user
try {
$this->activityPubManager->findActorOrCreate($apId);
$objects = $this->manager->findByApId($apId);
} catch (\Exception $e) {
$this->addFlash('error', $e->getMessage());
}
}
foreach ($result['errors'] as $error) {
/** @var \Exception $error */
$this->addFlash('error', $error->getMessage());
}

return $this->mapApResultsToViewModel($objects);
}

private function mapApResultsToViewModel(array $objects): array
{
return array_map(function ($object) {
if ($object instanceof Magazine) {
$type = 'magazine';
} elseif ($object instanceof User) {
$type = 'user';
} else {
$type = 'subject';
}

return [
'type' => $type,
'object' => $object,
];
}, $objects);
return $result['results'];
}
}
24 changes: 24 additions & 0 deletions src/DTO/SearchResponseDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\DTO;

use OpenApi\Attributes as OA;

/**
* This class is just used to have a single return type in case of an array that can contain multiple content types.
*/
#[OA\Schema()]
class SearchResponseDto
{
public function __construct(
public ?EntryResponseDto $entry = null,
public ?EntryCommentResponseDto $entryComment = null,
public ?PostResponseDto $post = null,
public ?PostCommentResponseDto $postComment = null,
public ?MagazineResponseDto $magazine = null,
public ?UserResponseDto $user = null,
) {
}
}
2 changes: 2 additions & 0 deletions src/Repository/SearchRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public function search(
?int $magazineId = null,
?string $specificType = null,
?\DateTimeImmutable $sinceDate = null,
int $perPage = SearchRepository::PER_PAGE,
): PagerfantaInterface {
$authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : '';
$magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : '';
Expand Down Expand Up @@ -262,6 +263,7 @@ public function search(
$adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer);

$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage($perPage);
$pagerfanta->setCurrentPage($page);

return $pagerfanta;
Expand Down
Loading
Loading