diff --git a/components/Attachment/Attachment.php b/components/Attachment/Attachment.php index 19faaab500..593070c7bd 100644 --- a/components/Attachment/Attachment.php +++ b/components/Attachment/Attachment.php @@ -77,7 +77,7 @@ class Attachment extends Component /** * Populate $note_expr with the criteria for looking for notes with attachments */ - public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool + public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool { $include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term; if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) { diff --git a/components/Collection/Collection.php b/components/Collection/Collection.php index 54b217cec6..409bc72c2e 100644 --- a/components/Collection/Collection.php +++ b/components/Collection/Collection.php @@ -23,12 +23,12 @@ class Collection extends Component * Supports a variety of query terms and is used both in feeds and * in search. Uses query builders to allow for extension */ - public static function query(string $query, int $page, ?string $language = null, ?Actor $actor = null): array + public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array { $note_criteria = null; $actor_criteria = null; if (!empty($query = trim($query))) { - [$note_criteria, $actor_criteria] = Parser::parse($query, $language, $actor); + [$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor); } $note_qb = DB::createQueryBuilder(); $actor_qb = DB::createQueryBuilder(); @@ -64,7 +64,7 @@ class Collection extends Component * Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text * notes, for different types of actors and for the content of text notes */ - public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr) + public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr) { if (str_contains($term, ':')) { $term = explode(':', $term); diff --git a/components/Collection/Util/Controller/Collection.php b/components/Collection/Util/Controller/Collection.php index 77bcdb82c5..aac4e8f54f 100644 --- a/components/Collection/Util/Controller/Collection.php +++ b/components/Collection/Util/Controller/Collection.php @@ -11,7 +11,7 @@ use Component\Collection\Collection as CollectionModule; class Collection extends Controller { - public function query(string $query, ?string $locale = null, ?Actor $actor = null) + public function query(string $query, ?string $locale = null, ?Actor $actor = null): array { $actor ??= Common::actor(); $locale ??= Common::currentLanguage()->getLocale(); diff --git a/components/Collection/Util/Parser.php b/components/Collection/Util/Parser.php index e16e246756..95724a3c11 100644 --- a/components/Collection/Util/Parser.php +++ b/components/Collection/Util/Parser.php @@ -53,7 +53,7 @@ abstract class Parser * * @return Criteria[] */ - public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array + public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array { if ($level === 0) { $input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&'); @@ -78,7 +78,7 @@ abstract class Parser $term = mb_substr($input, $left, $end ? null : $right - $left); $note_res = null; $actor_res = null; - Event::handle('CollectionQueryCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]); + Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]); if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line throw new ServerException("No one claimed responsibility for a match term: {$term}"); } diff --git a/components/Language/Language.php b/components/Language/Language.php index 3d47e12e2c..9d873fc8b1 100644 --- a/components/Language/Language.php +++ b/components/Language/Language.php @@ -60,7 +60,7 @@ class Language extends Component /** * Populate $note_expr or $actor_expr with an expression to match a language */ - public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool + public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool { $search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term; diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index 8dce380db6..ed75c6e6b2 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -47,17 +47,20 @@ use App\Util\Common; use App\Util\Exception\BugFoundException; use App\Util\Exception\NoSuchActorException; use App\Util\Nickname; +use Component\Collection\Util\Controller\OrderedCollection; use Component\FreeNetwork\Entity\FreeNetworkActorProtocol; use Component\FreeNetwork\Util\Discovery; use Exception; use InvalidArgumentException; use const PHP_URL_HOST; use Plugin\ActivityPub\Controller\Inbox; +use Plugin\ActivityPub\Controller\Outbox; use Plugin\ActivityPub\Entity\ActivitypubActivity; use Plugin\ActivityPub\Entity\ActivitypubActor; use Plugin\ActivityPub\Entity\ActivitypubObject; use Plugin\ActivityPub\Util\HTTPSignature; use Plugin\ActivityPub\Util\Model; +use Plugin\ActivityPub\Util\OrderedCollectionController; use Plugin\ActivityPub\Util\Response\ActorResponse; use Plugin\ActivityPub\Util\Response\NoteResponse; use Plugin\ActivityPub\Util\TypeResponse; @@ -127,7 +130,7 @@ class ActivityPub extends Plugin $r->connect( 'activitypub_actor_outbox', '/actor/{gsactor_id<\d+>}/outbox.json', - [Inbox::class, 'handle'], + [Outbox::class, 'viewOutboxByActorId'], options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]], ); return Event::next; @@ -185,16 +188,21 @@ class ActivityPub extends Plugin case 'actor_view_id': case 'actor_view_nickname': $response = ActorResponse::handle($vars['actor']); - return Event::stop; + break; case 'note_view': $response = NoteResponse::handle($vars['note']); - return Event::stop; + break; + case 'activitypub_actor_outbox': + $response = new TypeResponse($vars['type']); + break; default: - if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) === Event::stop) { - return Event::stop; + if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) { + if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) { + $response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']); + } } - return Event::next; } + return Event::stop; } /** diff --git a/plugins/ActivityPub/Controller/Outbox.php b/plugins/ActivityPub/Controller/Outbox.php new file mode 100644 index 0000000000..a9caba3b2e --- /dev/null +++ b/plugins/ActivityPub/Controller/Outbox.php @@ -0,0 +1,79 @@ +. +// }}} + +/** + * ActivityPub implementation for GNU social + * + * @package GNUsocial + * @category ActivityPub + * + * @author Diogo Peralta Cordeiro <@diogo.site> + * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ + +namespace Plugin\ActivityPub\Controller; + +use App\Core\DB\DB; +use function App\Core\I18n\_m; +use App\Core\Log; +use App\Core\Router\Router; +use App\Entity\Activity; +use App\Util\Exception\ClientException; +use Exception; +use Plugin\ActivityPub\Util\OrderedCollectionController; +use Symfony\Component\HttpFoundation\Request; + +/** + * ActivityPub Outbox Handler + * + * @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class Outbox extends OrderedCollectionController +{ + /** + * Create an Inbox Handler to receive something from someone. + */ + public function viewOutboxByActorId(Request $request, int $gsactor_id): array + { + try { + $user = DB::findOneBy('local_user', ['id' => $gsactor_id]); + } catch (Exception $e) { + throw new ClientException(_m('No such actor.'), 404, $e); + } + + $this->actor_id = $gsactor_id; + + Log::debug('ActivityPub Outbox: Received a GET request.'); + + $activities = DB::findBy(Activity::class, ['actor_id' => $user->getId()], order_by: ['created' => 'DESC']); + + foreach ($activities as $act) { + $this->ordered_items[] = Router::url('activity_view', ['id' => $act->getId()], ROUTER::ABSOLUTE_URL); + } + + $this->route = 'activitypub_actor_outbox'; + $this->route_args = ['gsactor_id' => $user->getId(), 'page' => $this->int('page') ?? 0]; + + return $this->handle($request); + } +} diff --git a/plugins/ActivityPub/Util/OrderedCollectionController.php b/plugins/ActivityPub/Util/OrderedCollectionController.php new file mode 100644 index 0000000000..c5df7d6658 --- /dev/null +++ b/plugins/ActivityPub/Util/OrderedCollectionController.php @@ -0,0 +1,112 @@ +. +// }}} + +/** + * ActivityPub implementation for GNU social + * + * @package GNUsocial + * @category ActivityPub + * + * @author Diogo Peralta Cordeiro <@diogo.site> + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ + +namespace Plugin\ActivityPub\Util; + +use ActivityPhp\Type\Core\OrderedCollection; +use ActivityPhp\Type\Core\OrderedCollectionPage; +use App\Core\Router\Router; +use Component\Collection\Util\Controller\CircleController; +use Component\Collection\Util\Controller\FeedController; +use Component\Collection\Util\Controller\OrderedCollection as GSOrderedCollection; +use Symfony\Component\HttpFoundation\Request; + +/** + * Provides a response in application/ld+json to GSActivity + * + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +abstract class OrderedCollectionController extends GSOrderedCollection +{ + protected array $ordered_items = []; + protected string $route; + protected array $route_args = []; + protected int $actor_id; + + public static function fromControllerVars(array $vars): array + { + $route = $vars['request']->get('_route'); + $route_args = array_merge($vars['request']->query->all(), $vars['request']->attributes->get('_route_params')); + unset($route_args['is_system_path'], $route_args['template'], $route_args['_format'], $route_args['accept'], $route_args['p']); + if (is_subclass_of($vars['controller'][0], FeedController::class)) { + $notes = []; + foreach ($vars['notes'] as $note_replies) { + $notes[] = Router::url('note_view', ['id' => $note_replies['note']->getId()], type: Router::ABSOLUTE_URL); + } + $type = self::setupType(route: $route, route_args: $route_args, ordered_items: $notes); + } elseif (is_subclass_of($vars['controller'][0], CircleController::class)) { + $actors = []; + foreach ($vars['actors'] as $actor) { + $actors[] = Router::url('actor_view_id', ['id' => $actor->getId()], type: Router::ABSOLUTE_URL); + } + $type = self::setupType(route: $route, route_args: $route_args, ordered_items: $actors); + } else { + $type = self::setupType(route: $route, route_args: $route_args, ordered_items: []); + } + return ['type' => $type]; + } + + protected static function setupType(string $route, array $route_args = [], array $ordered_items = []): OrderedCollectionPage|OrderedCollection + { + $page = $route_args['page'] ?? 0; + $type = $page === 0 ? new OrderedCollection() : new OrderedCollectionPage(); + $type->set('@context', 'https://www.w3.org/ns/activitystreams'); + $type->set('items', $ordered_items); + $type->set('orderedItems', $ordered_items); + $type->set('totalItems', \count($ordered_items)); + if ($page === 0) { + $route_args['page'] = 1; + $type->set('first', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL)); + } else { + $type->set('partOf', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL)); + + if ($page + 1 < $total_pages = 1) { // TODO: do proper pagination + $route_args['page'] = ($page + 1 == 1 ? 2 : $page + 1); + $type->set('next', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL)); + } + + if ($page > 1) { + $route_args['page'] = ($page - 1 <= 0 ? 1 : $page - 1); + $type->set('prev', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL)); + } + } + return $type; + } + + public function handle(Request $request): array + { + $type = self::setupType($this->route, $this->route_args, $this->ordered_items, $this->actor_id); + + return ['type' => $type]; + } +} diff --git a/plugins/ActivityPub/Util/TypeResponse.php b/plugins/ActivityPub/Util/TypeResponse.php index 45883096d4..3a2b586861 100644 --- a/plugins/ActivityPub/Util/TypeResponse.php +++ b/plugins/ActivityPub/Util/TypeResponse.php @@ -49,7 +49,7 @@ class TypeResponse extends JsonResponse * @param null|AbstractObject|string $json * @param int $status The response status code */ - public function __construct(string|AbstractObject|null $json = null, int $status = 202) + public function __construct(string|AbstractObject|null $json = null, int $status = 200) { parent::__construct( data: \is_object($json) ? $json->toJson() : $json,