[PLUGIN][ActivityPub] Sign outgoing GET requests on behalf of relevant actor
This commit is contained in:
parent
3b3ded5212
commit
2df30e2987
@ -296,11 +296,11 @@ class ActivityPub extends Plugin
|
||||
*
|
||||
* @return bool true if imported, false otherwise
|
||||
*/
|
||||
public static function freeNetworkGrabRemote(string $uri): bool
|
||||
public static function freeNetworkGrabRemote(string $uri, ?Actor $on_behalf_of = null): bool
|
||||
{
|
||||
if (Common::isValidHttpUrl($uri)) {
|
||||
try {
|
||||
$object = self::getObjectByUri($uri);
|
||||
$object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of);
|
||||
if (!\is_null($object)) {
|
||||
if ($object instanceof Type\AbstractObject) {
|
||||
if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) {
|
||||
@ -540,7 +540,7 @@ class ActivityPub extends Plugin
|
||||
*
|
||||
* @return null|Actor|mixed|Note got from URI
|
||||
*/
|
||||
public static function getObjectByUri(string $resource, bool $try_online = true): mixed
|
||||
public static function getObjectByUri(string $resource, bool $try_online = true, ?Actor $on_behalf_of = null): mixed
|
||||
{
|
||||
// Try known object
|
||||
$known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
|
||||
@ -556,7 +556,7 @@ class ActivityPub extends Plugin
|
||||
|
||||
// Try Actor
|
||||
try {
|
||||
return Explorer::getOneFromUri($resource, try_online: false);
|
||||
return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of);
|
||||
} catch (\Exception) {
|
||||
// Ignore, this is brute forcing, it's okay not to find
|
||||
}
|
||||
@ -592,7 +592,7 @@ class ActivityPub extends Plugin
|
||||
throw new Exception("Remote resource {$resource} not found without online resources.");
|
||||
}
|
||||
|
||||
$response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
|
||||
$response = Explorer::get($resource, $on_behalf_of);
|
||||
// If it was deleted
|
||||
if ($response->getStatusCode() == 410) {
|
||||
//$obj = Type::create('Tombstone', ['id' => $resource]);
|
||||
|
@ -32,12 +32,14 @@ declare(strict_types = 1);
|
||||
|
||||
namespace Plugin\ActivityPub\Controller;
|
||||
|
||||
use ActivityPhp\Type\AbstractObject;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Queue;
|
||||
use App\Core\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use Exception;
|
||||
@ -95,10 +97,12 @@ class Inbox extends Controller
|
||||
return $error('Actor not found in the request.');
|
||||
}
|
||||
|
||||
$to_actor = $this->deriveActivityTo($type);
|
||||
|
||||
try {
|
||||
$resource_parts = parse_url($type->get('actor'));
|
||||
if ($resource_parts['host'] !== Common::config('site', 'server')) {
|
||||
$actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor')));
|
||||
$actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor'), try_online: true, on_behalf_of: $to_actor));
|
||||
$ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()]);
|
||||
} else {
|
||||
throw new Exception('Only remote actors can use this endpoint.');
|
||||
@ -136,7 +140,7 @@ class Inbox extends Controller
|
||||
// If the signature fails verification the first time, update profile as it might have changed public key
|
||||
if ($verified !== 1) {
|
||||
try {
|
||||
$res = Explorer::getRemoteActorActivity($ap_actor->getUri());
|
||||
$res = Explorer::getRemoteActorActivity($ap_actor->getUri(), $to_actor);
|
||||
if (\is_null($res)) {
|
||||
return $error('Invalid remote actor (null response).');
|
||||
}
|
||||
@ -169,4 +173,68 @@ class Inbox extends Controller
|
||||
|
||||
return new TypeResponse($type, status: 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poke at the given AbstractObject to find out who it is 'to'.
|
||||
* Function will check through the 'to', 'cc', and 'object' fields
|
||||
* of the given type (in that order) to check if if points to anyone
|
||||
* on our instance. The first found candidate will be returned.
|
||||
*
|
||||
* @param AbstractObject $type
|
||||
*
|
||||
* @return Actor|null The discovered actor, if found. null otherwise.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function deriveActivityTo(AbstractObject $type): Actor|null
|
||||
{
|
||||
foreach (['to', 'cc'] as $field) {
|
||||
foreach ((array) $type->get($field) as $uri) {
|
||||
$actor = self::uriToMaybeLocalActor($uri);
|
||||
if (!\is_null($actor)) {
|
||||
return $actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if it's not to or cc anyone we have to dive deeper
|
||||
if ($type->has('object')) {
|
||||
// the 'object' field might just be a uri of one
|
||||
// of our Actors, if this is a follow or block
|
||||
$object = $type->get('object');
|
||||
if (\is_string($object)) {
|
||||
$actor = self::uriToMaybeLocalActor($object);
|
||||
if (!\is_null($actor)) {
|
||||
return $actor;
|
||||
}
|
||||
} else if ($object instanceof AbstractObject) {
|
||||
// if the inner object is also a Type, repeat the process
|
||||
return $this->deriveActivityTo($object);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local actor that owns or corresponds to given uri.
|
||||
*
|
||||
* @param string $uri
|
||||
*
|
||||
* @return Actor|null
|
||||
*/
|
||||
private static function uriToMaybeLocalActor(string $uri): Actor|null
|
||||
{
|
||||
$parsed = parse_url($uri);
|
||||
// check if this uri belongs to us
|
||||
if ($parsed['host'] === Common::config('site', 'server')) {
|
||||
// it is our uri so we should be able to get
|
||||
// the actor without making any remote calls
|
||||
$actor = Explorer::getLocalActorForPath($parsed['path']);
|
||||
if (!\is_null($actor)) {
|
||||
return $actor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ use App\Core\HTTPClient;
|
||||
use App\Core\Log;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\NoSuchActorException;
|
||||
use App\Util\Nickname;
|
||||
@ -49,6 +50,7 @@ use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* ActivityPub's own Explorer
|
||||
@ -65,17 +67,19 @@ class Explorer
|
||||
/**
|
||||
* Shortcut function to get a single profile from its URL.
|
||||
*
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
*
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
* @param Actor $on_behalf_of AP Actor on behalf of whom any remote lookups are to be performed, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws NoSuchActorException
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public static function getOneFromUri(string $uri, bool $try_online = true): Actor
|
||||
public static function getOneFromUri(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): Actor
|
||||
{
|
||||
$actors = (new self())->lookup($uri, $try_online);
|
||||
$actors = (new self())->lookup($uri, $try_online, $on_behalf_of);
|
||||
switch (\count($actors)) {
|
||||
case 1:
|
||||
return $actors[0];
|
||||
@ -91,8 +95,10 @@ class Explorer
|
||||
* This function cleans the $this->discovered_actor_profiles array
|
||||
* so that there is no erroneous data
|
||||
*
|
||||
* @param string $uri User's url
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
* @param string $uri User's url
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
* @param Actor $on_behalf_of AP Actor on behalf of whom the lookup is being performed, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws NoSuchActorException
|
||||
@ -102,7 +108,7 @@ class Explorer
|
||||
*
|
||||
* @return array of Actor objects
|
||||
*/
|
||||
public function lookup(string $uri, bool $try_online = true): array
|
||||
public function lookup(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): array
|
||||
{
|
||||
if (\in_array($uri, ActivityPub::PUBLIC_TO)) {
|
||||
return [];
|
||||
@ -111,7 +117,7 @@ class Explorer
|
||||
Log::debug('ActivityPub Explorer: Started now looking for ' . $uri);
|
||||
$this->discovered_actors = [];
|
||||
|
||||
return $this->_lookup($uri, $try_online);
|
||||
return $this->_lookup($uri, $try_online, $on_behalf_of);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,8 +125,10 @@ class Explorer
|
||||
* This is a recursive function that will accumulate the results on
|
||||
* $discovered_actor_profiles array
|
||||
*
|
||||
* @param string $uri User's url
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
* @param string $uri User's url
|
||||
* @param bool $try_online whether to try online grabbing, defaults to true
|
||||
* @param Actor $on_behalf_of Actor on behalf of whom the lookup is being performed, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws NoSuchActorException
|
||||
@ -130,13 +138,13 @@ class Explorer
|
||||
*
|
||||
* @return array of Actor objects
|
||||
*/
|
||||
private function _lookup(string $uri, bool $try_online = true): array
|
||||
private function _lookup(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): array
|
||||
{
|
||||
$grab_known = $this->grabKnownActor($uri);
|
||||
|
||||
// First check if we already have it locally and, if so, return it.
|
||||
// If the known fetch fails and remote grab is required: store locally and return.
|
||||
if (!$grab_known && (!$try_online || !$this->grabRemoteActor($uri))) {
|
||||
if (!$grab_known && (!$try_online || !$this->grabRemoteActor($uri, $on_behalf_of))) {
|
||||
throw new NoSuchActorException('Actor not found.');
|
||||
}
|
||||
|
||||
@ -158,32 +166,25 @@ class Explorer
|
||||
{
|
||||
Log::debug('ActivityPub Explorer: Searching locally for ' . $uri . ' offline.');
|
||||
|
||||
// Try local
|
||||
if (Common::isValidHttpUrl($uri)) {
|
||||
// This means $uri is a valid url
|
||||
$resource_parts = parse_url($uri);
|
||||
// TODO: Use URLMatcher
|
||||
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||
$str = $resource_parts['path'];
|
||||
// actor_view_nickname
|
||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
||||
// actor_view_id
|
||||
$reuri = '/\/actor\/(\d+)\/?/m';
|
||||
if (preg_match_all($renick, $str, $matches, \PREG_SET_ORDER, 0) === 1) {
|
||||
$this->discovered_actors[] = DB::findOneBy(
|
||||
LocalUser::class,
|
||||
['nickname' => $matches[0][1]],
|
||||
)->getActor();
|
||||
return true;
|
||||
} elseif (preg_match_all($reuri, $str, $matches, \PREG_SET_ORDER, 0) === 1) {
|
||||
$this->discovered_actors[] = Actor::getById((int) $matches[0][1]);
|
||||
return true;
|
||||
}
|
||||
if (!Common::isValidHttpUrl($uri)) {
|
||||
Log::debug('ActivityPub Explorer: URI ' . $uri . ' was not a valid http url.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if uri corresponds to local actor
|
||||
$resource_parts = parse_url($uri);
|
||||
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||
$actor = $this::getLocalActorForPath($resource_parts['path']);
|
||||
if (!\is_null($actor)) {
|
||||
Log::debug('ActivityPub Explorer: Found local ActivityPub Actor for ' . $uri);
|
||||
$this->discovered_actors[] = $actor;
|
||||
return true;
|
||||
} else {
|
||||
Log::debug('ActivityPub Explorer: Unable to find a known local ActivityPub Actor for ' . $uri);
|
||||
}
|
||||
}
|
||||
|
||||
// Try standard ActivityPub route
|
||||
// Is this a known filthy little mudblood?
|
||||
// URI isn't for a local actor, try to get by URI more generally
|
||||
$aprofile = DB::findOneBy(ActivitypubActor::class, ['uri' => $uri], return_null: true);
|
||||
if (!\is_null($aprofile)) {
|
||||
Log::debug('ActivityPub Explorer: Found a known ActivityPub Actor for ' . $uri);
|
||||
@ -200,7 +201,9 @@ class Explorer
|
||||
* Get a remote user(s) profile(s) from its URL and joins it on
|
||||
* $this->discovered_actor_profiles
|
||||
*
|
||||
* @param string $uri User's url
|
||||
* @param string $uri User's url
|
||||
* @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws NoSuchActorException
|
||||
@ -210,10 +213,9 @@ class Explorer
|
||||
*
|
||||
* @return bool success state
|
||||
*/
|
||||
private function grabRemoteActor(string $uri): bool
|
||||
private function grabRemoteActor(string $uri, ?Actor $on_behalf_of = null): bool
|
||||
{
|
||||
Log::debug('ActivityPub Explorer: Trying to grab a remote actor for ' . $uri);
|
||||
$response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]);
|
||||
$response = $this->get($uri, $on_behalf_of);
|
||||
$res = json_decode($response->getContent(), true);
|
||||
if ($response->getStatusCode() == 410) { // If it was deleted
|
||||
return true; // Nothing to add.
|
||||
@ -227,7 +229,7 @@ class Explorer
|
||||
|
||||
if ($res['type'] === 'OrderedCollection') { // It's a potential collection of actors!!!
|
||||
Log::debug('ActivityPub Explorer: Found a collection of actors for ' . $uri);
|
||||
$this->travelCollection($res['first']);
|
||||
$this->travelCollection($res['first'], $on_behalf_of);
|
||||
return true;
|
||||
} else {
|
||||
try {
|
||||
@ -249,15 +251,19 @@ class Explorer
|
||||
/**
|
||||
* Allows the Explorer to transverse a collection of persons.
|
||||
*
|
||||
* @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
* @param string $uri Collection's url
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws NoSuchActorException
|
||||
* @throws RedirectionExceptionInterface
|
||||
* @throws ServerExceptionInterface
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
private function travelCollection(string $uri): bool
|
||||
private function travelCollection(string $uri, ?Actor $on_behalf_of = null): bool
|
||||
{
|
||||
$response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]);
|
||||
$response = $this->get($uri, $on_behalf_of);
|
||||
$res = json_decode($response->getContent(), true);
|
||||
|
||||
if (!isset($res['orderedItems'])) {
|
||||
@ -266,22 +272,47 @@ class Explorer
|
||||
|
||||
// Accumulate findings
|
||||
foreach ($res['orderedItems'] as $actor_uri) {
|
||||
$this->_lookup($actor_uri);
|
||||
$this->_lookup($actor_uri, true, $on_behalf_of);
|
||||
}
|
||||
|
||||
// Go through entire collection
|
||||
if (!\is_null($res['next'])) {
|
||||
$this->travelCollection($res['next']);
|
||||
$this->travelCollection($res['next'], $on_behalf_of);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an http GET request to the given uri. Will be http-signed on behalf of given Actor, if provided.
|
||||
*
|
||||
* @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
* @param string $uri uri of remote resource, expected to return an Activity/Object of some kind.
|
||||
*
|
||||
* @return ResponseInterface The http response, for further processing.
|
||||
*/
|
||||
public static function get(string $uri, ?Actor $on_behalf_of = null): ResponseInterface
|
||||
{
|
||||
$headers = [];
|
||||
if (!\is_null($on_behalf_of)) {
|
||||
// sign the http GET request
|
||||
$headers = HTTPSignature::sign($on_behalf_of, $uri, body: false, addlHeaders: [], method: 'get');
|
||||
} else {
|
||||
// just do a bare request
|
||||
$headers = ACTIVITYPUB::HTTP_CLIENT_HEADERS;
|
||||
}
|
||||
|
||||
return HTTPClient::get($uri, ['headers' => $headers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a remote user array from its URL (this function is only used for
|
||||
* profile updating and shall not be used for anything else)
|
||||
*
|
||||
* @param string $uri User's url
|
||||
* @param string $uri User's url
|
||||
* @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null.
|
||||
* If null, outgoing GET request(s) will not be http signed.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws Exception
|
||||
@ -292,9 +323,9 @@ class Explorer
|
||||
* @return null|string If it is able to fetch, false if it's gone
|
||||
* // Exceptions when network issues or unsupported Activity format
|
||||
*/
|
||||
public static function getRemoteActorActivity(string $uri): string|null
|
||||
public static function getRemoteActorActivity(string $uri, ?Actor $on_behalf_of = null): string|null
|
||||
{
|
||||
$response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]);
|
||||
$response = Explorer::get($uri, $on_behalf_of);
|
||||
// If it was deleted
|
||||
if ($response->getStatusCode() == 410) {
|
||||
return null;
|
||||
@ -303,4 +334,37 @@ class Explorer
|
||||
}
|
||||
return $response->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given path and return the actor it corresponds to.
|
||||
*
|
||||
* @param String $path Path on *this instance*. Will be parsed with regular expressions.
|
||||
* Something like `/actor/1` or `/object/note/1`.
|
||||
*
|
||||
* @return Actor|null The actor corresponding to/owning the given uri, null if not found.
|
||||
*/
|
||||
public static function getLocalActorForPath(string $path): Actor|null
|
||||
{
|
||||
// TODO: Use URLMatcher
|
||||
|
||||
// actor_view_nickname
|
||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/';
|
||||
if (preg_match_all($renick, $path, $matches, \PREG_SET_ORDER, 0) === 1) {
|
||||
return DB::findOneBy(LocalUser::class, ['nickname' => $matches[0][1]])->getActor();
|
||||
}
|
||||
|
||||
// actor_view_id
|
||||
$reuri = '/\/actor\/(\d+)\/?/';
|
||||
if (preg_match_all($reuri, $path, $matches, \PREG_SET_ORDER, 0) === 1) {
|
||||
return Actor::getById((int) $matches[0][1]);
|
||||
}
|
||||
|
||||
// note / page / article match
|
||||
$renote = '/\/object\/(?:note|page|article)\/(\d+)\/?/';
|
||||
if (preg_match_all($renote, $path, $matches, \PREG_SET_ORDER, 0) === 1) {
|
||||
return Note::getById((int) $matches[0][1])->getActor();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -47,13 +47,13 @@ class HTTPSignature
|
||||
*
|
||||
* @return array Headers to be used in request
|
||||
*/
|
||||
public static function sign(Actor $user, string $url, string|bool $body = false, array $addlHeaders = []): array
|
||||
public static function sign(Actor $user, string $url, string|bool $body = false, array $addlHeaders = [], string $method = 'post'): array
|
||||
{
|
||||
$digest = false;
|
||||
if ($body) {
|
||||
$digest = self::_digest($body);
|
||||
}
|
||||
$headers = self::_headersToSign($url, $digest);
|
||||
$headers = self::_headersToSign($url, $digest, $method);
|
||||
$headers = array_merge($headers, $addlHeaders);
|
||||
$stringToSign = self::_headersToSigningString($headers);
|
||||
$signedHeaders = implode(' ', array_map('strtolower', array_keys($headers)));
|
||||
@ -81,14 +81,23 @@ class HTTPSignature
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a canonical array of http headers, ready to be signed.
|
||||
*
|
||||
* @param string $uri uri of destination
|
||||
* @param string|bool $digest digest of the request body to add to the `Digest` header (optional).
|
||||
* @param string $method http method (GET, POST, etc) that the request will use.
|
||||
* This will be used in the `(request-target)` part of the signature.
|
||||
*
|
||||
* @return array Headers to be signed.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected static function _headersToSign(string $url, string|bool $digest = false): array
|
||||
protected static function _headersToSign(string $url, string|bool $digest = false, string $method): array
|
||||
{
|
||||
$date = new DateTime('UTC');
|
||||
|
||||
$headers = [
|
||||
'(request-target)' => 'post ' . parse_url($url, \PHP_URL_PATH),
|
||||
'(request-target)' => strtolower($method) . ' ' . parse_url($url, \PHP_URL_PATH),
|
||||
'Date' => $date->format('D, d M Y H:i:s \G\M\T'),
|
||||
'Host' => parse_url($url, \PHP_URL_HOST),
|
||||
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, application/json',
|
||||
|
Loading…
x
Reference in New Issue
Block a user