diff --git a/components/FreeNetwork/Entity/FreeNetworkActorProtocol.php b/components/FreeNetwork/Entity/FreeNetworkActorProtocol.php new file mode 100644 index 0000000000..948d7febca --- /dev/null +++ b/components/FreeNetwork/Entity/FreeNetworkActorProtocol.php @@ -0,0 +1,180 @@ +. + +// }}} + +/** + * ActivityPub implementation for GNU social + * + * @package GNUsocial + * + * @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 Component\FreeNetwork\Entity; + +use App\Core\Cache; +use App\Core\DB\DB; +use App\Core\Entity; +use function App\Core\I18n\_m; +use App\Core\Log; +use App\Entity\Actor; +use Component\FreeNetwork\Util\Discovery; +use DateTimeInterface; +use Exception; +use Plugin\ActivityPub\Util\DiscoveryHints; +use Plugin\ActivityPub\Util\Explorer; + +/** + * Table Definition for free_network_actor_protocol + * + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class FreeNetworkActorProtocol extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart; + private int $actor_id; + private ?string $protocol; + private ?string $addr; + private DateTimeInterface $created; + private DateTimeInterface $modified; + + public function getActorId(): int + { + return $this->actor_id; + } + + public function setActorId(int $actor_id): self + { + $this->actor_id = $actor_id; + return $this; + } + + public function setProtocol(?string $protocol): self + { + $this->protocol = $protocol; + return $this; + } + + public function getProtocol(): ?string + { + return $this->protocol; + } + + public function setAddr(string $addr): self + { + $this->addr = $addr; + return $this; + } + + public function getAddr(): string + { + return $this->addr; + } + + public function getCreated(): DateTimeInterface + { + return $this->created; + } + + public function setCreated(DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function protocolSucceeded(string $protocol, int|Actor $actor_id, string $addr): void + { + $actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId(); + $attributed_protocol = self::getWithPK(['actor_id' => $actor_id]); + if (is_null($attributed_protocol)) { + $attributed_protocol = self::create([ + 'actor_id' => $actor_id, + 'protocol' => $protocol, + 'addr' => Discovery::normalize($addr), + ]); + } else { + $attributed_protocol->setProtocol($protocol); + } + DB::wrapInTransaction(fn() => DB::persist($attributed_protocol)); + } + + public static function canIActor(string $protocol, int|Actor $actor_id): bool + { + $actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId(); + $attributed_protocol = self::getWithPK(['actor_id' => $actor_id])?->getProtocol(); + if (is_null($attributed_protocol)) { + // If it is not attributed, you can go ahead. + return true; + } else { + // If it is attributed, you can on the condition that you're assigned to it. + return $attributed_protocol === $protocol; + } + } + + public static function canIAddr(string $protocol, string $target): bool + { + // Normalize $addr, i.e. add 'acct:' if missing + $addr = Discovery::normalize($target); + $attributed_protocol = self::getWithPK(['addr' => $addr])?->getProtocol(); + if (is_null($attributed_protocol)) { + // If it is not attributed, you can go ahead. + return true; + } else { + // If it is attributed, you can on the condition that you're assigned to it. + return $attributed_protocol === $protocol; + } + } + + public static function schemaDef(): array + { + return [ + 'name' => 'free_network_actor_protocol', + 'fields' => [ + 'actor_id' => ['type' => 'int', 'not null' => true], + 'protocol' => ['type' => 'varchar', 'length' => 32, 'description' => 'the protocol plugin that should handle federation of this actor'], + 'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'], + 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['actor_id'], + 'foreign keys' => [ + 'activitypub_actor_actor_id_fkey' => ['actor', ['actor_id' => 'id']], + ], + ]; + } +} diff --git a/components/FreeNetwork/FreeNetwork.php b/components/FreeNetwork/FreeNetwork.php index aca11f4b24..ee22bf7579 100644 --- a/components/FreeNetwork/FreeNetwork.php +++ b/components/FreeNetwork/FreeNetwork.php @@ -22,8 +22,11 @@ declare(strict_types = 1); namespace Component\FreeNetwork; use App\Core\Event; +use App\Core\GSFile; +use App\Core\HTTPClient; use App\Entity\Activity; use Plugin\ActivityPub\Entity\ActivitypubActor; +use XML_XRD; use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Modules\Component; @@ -304,7 +307,7 @@ class FreeNetwork extends Component // In the regexp below we need to match / _before_ URL_REGEX_VALID_PATH_CHARS because it otherwise gets merged // with the TLD before (but / is in URL_REGEX_VALID_PATH_CHARS anyway, it's just its positioning that is important) $result = preg_match_all( - '/(?:^|\s+)' . preg_quote($preMention, '/') . '(' . URL_REGEX_DOMAIN_NAME . '(?:\/[' . URL_REGEX_VALID_PATH_CHARS . ']*)*)/', + '/' . Nickname::BEFORE_MENTIONS . preg_quote($preMention, '/') . '(' . URL_REGEX_DOMAIN_NAME . '(?:\/[' . URL_REGEX_VALID_PATH_CHARS . ']*)*)/', $text, $wmatches, PREG_OFFSET_CAPTURE @@ -362,46 +365,72 @@ class FreeNetwork extends Component foreach (self::extractUrlMentions($text) as $wmatch) { [$target, $pos] = $wmatch; - $schemes = ['https', 'http']; - foreach ($schemes as $scheme) { - $url = "$scheme://$target"; - if (Common::isValidHttpUrl($url)) { - // This means $resource is a valid url - Log::info("Checking actor address '$url'"); - $actor = null; - $resource_parts = parse_url($url); - // TODO: Use URLMatcher - if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: 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) { - $actor = LocalUser::getWithPK(['nickname' => $matches[0][1]])->getActor(); - } elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) { - $actor = Actor::getById((int) $matches[0][1]); - } else { - Log::error('Unexpected behaviour onEndFindMentions at FreeNetwork'); - throw new ServerException('Unexpected behaviour onEndFindMentions at FreeNetwork'); - } + $url = "https://$target"; + if (Common::isValidHttpUrl($url)) { + // This means $resource is a valid url + $resource_parts = parse_url($url); + // TODO: Use URLMatcher + if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: 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) { + $actor = LocalUser::getWithPK(['nickname' => $matches[0][1]])->getActor(); + } elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) { + $actor = Actor::getById((int) $matches[0][1]); } else { - Event::handle('FreeNetworkFindUrlMentions', [$url, &$actor]); - if (is_null($actor)) { - continue; - } + Log::error('Unexpected behaviour onEndFindMentions at FreeNetwork'); + throw new ServerException('Unexpected behaviour onEndFindMentions at FreeNetwork'); } - $displayName = $actor->getFullname() ?? $actor->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here - $matches[$pos] = ['mentioned' => [$actor], - 'type' => 'mention', - 'text' => $displayName, - 'position' => $pos, - 'length' => mb_strlen($target), - 'url' => $actor->getUri() - ]; } else { - break; + Log::info("Checking actor address '$url'"); + + $link = new XML_XRD_Element_Link( + Discovery::LRDD_REL, + 'https://' . parse_url($url, PHP_URL_HOST) . '/.well-known/webfinger?resource={uri}', + Discovery::JRD_MIMETYPE, + true // isTemplate + ); + $xrd_uri = Discovery::applyTemplate($link->template, $url); + $response = HTTPClient::get($xrd_uri, ['headers' => ['Accept' => $link->type]]); + if ($response->getStatusCode() !== 200) { + continue; + } + + $xrd = new XML_XRD(); + + switch (GSFile::mimetypeBare($response->getHeaders()['content-type'][0])) { + case Discovery::JRD_MIMETYPE_OLD: + case Discovery::JRD_MIMETYPE: + $type = 'json'; + break; + case Discovery::XRD_MIMETYPE: + $type = 'xml'; + break; + default: + // fall back to letting XML_XRD auto-detect + Log::debug('No recognized content-type header for resource descriptor body on ' . $xrd_uri); + $type = null; + } + $xrd->loadString($response->getContent(), $type); + + $actor = null; + Event::handle('FreeNetworkFoundXrd', [$xrd, &$actor]); + if (is_null($actor)) { + continue; + } } + $displayName = $actor->getFullname() ?? $actor->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here + $matches[$pos] = [ + 'mentioned' => [$actor], + 'type' => 'mention', + 'text' => $displayName, + 'position' => $pos, + 'length' => mb_strlen($target), + 'url' => $actor->getUri() + ]; } } @@ -425,9 +454,12 @@ class FreeNetwork extends Component { $protocols = []; Event::handle('AddFreeNetworkProtocol', [&$protocols]); + $delivered = []; foreach ($protocols as $protocol) { - $protocol::freeNetworkDistribute($sender, $activity, $targets, $reason); + $protocol::freeNetworkDistribute($sender, $activity, $targets, $reason, $delivered); } + $failed_targets = array_udiff($targets, $delivered, function(Actor $a, Actor $b):int {return $a->getId() <=> $b->getId();}); + // TODO: Implement failed queues return false; } diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index 1bbc4eb6d7..5a67b41732 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -16,6 +16,8 @@ use App\Entity\LocalUser; use App\Util\Common; use App\Util\Exception\NoSuchActorException; use App\Util\Nickname; +use Component\FreeNetwork\Entity\FreeNetworkActorProtocol; +use Component\FreeNetwork\Util\Discovery; use Exception; use Plugin\ActivityPub\Controller\Inbox; use Plugin\ActivityPub\Entity\ActivitypubActor; @@ -29,6 +31,7 @@ use Symfony\Contracts\HttpClient\ResponseInterface; use XML_XRD; use XML_XRD_Element_Link; use function count; +use function Psy\debug; use const PREG_SET_ORDER; class ActivityPub extends Plugin @@ -109,33 +112,46 @@ class ActivityPub extends Plugin return Event::next; } - public static function freeNetworkDistribute(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool + public static function freeNetworkDistribute(Actor $sender, Activity $activity, array $targets, ?string $reason = null, array &$delivered = []): bool { $to_addr = []; - foreach ($targets as $target) { - if (is_null($ap_target = ActivitypubActor::getWithPK(['actor_id' => $target->getId()]))) { - continue; + foreach ($targets as $actor) { + if (FreeNetworkActorProtocol::canIActor('activitypub', $actor->getId())) { + if (is_null($ap_target = ActivitypubActor::getWithPK(['actor_id' => $actor->getId()]))) { + continue; + } + $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor; + } else { + return Event::next; } - $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()] = true; } $errors = []; - $to_failed = []; // TODO: Implement failed queues + //$to_failed = []; foreach ($to_addr as $inbox => $dummy) { try { $res = self::postman($sender, EntityToType::translate($activity), $inbox); - // accummulate errors for later use, if needed + // accumulate errors for later use, if needed $status_code = $res->getStatusCode(); if (!($status_code === 200 || $status_code === 202 || $status_code === 409)) { $res_body = json_decode($res->getContent(), true); $errors[] = isset($res_body['error']) ? $res_body['error'] : "An unknown error occurred."; - $to_failed[$inbox] = $activity; + //$to_failed[$inbox] = $activity; + } else { + array_push($delivered, ...$dummy); + foreach ($dummy as $actor) { + FreeNetworkActorProtocol::protocolSucceeded( + 'activitypub', + $actor, + Discovery::normalize($actor->getNickname() . '@' . parse_url($inbox, PHP_URL_HOST)) + ); + } } } catch (Exception $e) { Log::error('ActivityPub @ freeNetworkDistribute: ' . $e->getMessage()); - $to_failed[$inbox] = $activity; + //$to_failed[$inbox] = $activity; } } @@ -259,20 +275,39 @@ class ActivityPub extends Plugin public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): bool { try { - $ap_actor = ActivitypubActor::getByAddr($target); - $actor = Actor::getById($ap_actor->getActorId()); - return Event::stop; + if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) { + $ap_actor = ActivitypubActor::getByAddr($addr); + $actor = Actor::getById($ap_actor->getActorId()); + FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor->getId(), $addr); + return Event::stop; + } else { + return Event::next; + } } catch (Exception $e) { Log::error("ActivityPub Webfinger Mention check failed: " . $e->getMessage()); return Event::next; } } - public function onFreeNetworkFindUrlMentions(string $url, ?Actor &$actor = null): bool + public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): bool { + $addr = null; + foreach ($xrd->aliases as $alias) { + if (Discovery::isAcct($alias)) { + $addr = Discovery::normalize($alias); + } + } + if (is_null($addr)) { + return Event::next; + } else { + if (!FreeNetworkActorProtocol::canIAddr('activitypub', $addr)) { + return Event::next; + } + } try { - $ap_actor = ActivitypubActor::fromUri($url); + $ap_actor = ActivitypubActor::fromXrd($addr, $xrd); $actor = Actor::getById($ap_actor->getActorId()); + FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor, $addr); return Event::stop; } catch (Exception $e) { Log::error("ActivityPub Actor from URL Mention check failed: " . $e->getMessage()); diff --git a/plugins/ActivityPub/Controller/Inbox.php b/plugins/ActivityPub/Controller/Inbox.php index a51d58b703..2ef9042e21 100644 --- a/plugins/ActivityPub/Controller/Inbox.php +++ b/plugins/ActivityPub/Controller/Inbox.php @@ -28,6 +28,7 @@ use App\Core\DB\DB; use App\Core\Log; use App\Core\Router\Router; use App\Entity\Actor; +use Component\FreeNetwork\Entity\FreeNetworkActorProtocol; use Exception; use Plugin\ActivityPub\Entity\ActivitypubActor; use Plugin\ActivityPub\Entity\ActivitypubRsa; @@ -128,6 +129,7 @@ class Inbox extends Controller // Store Activity $ap_act = AS2ToEntity::store(activity: $type->toArray(), source: 'ActivityPub'); + FreeNetworkActorProtocol::protocolSucceeded('activitypub', $actor->getId()); DB::flush(); dd($ap_act, $act = $ap_act->getActivity(), $act->getActor(), $act->getObject()); diff --git a/plugins/ActivityPub/Entity/ActivitypubActor.php b/plugins/ActivityPub/Entity/ActivitypubActor.php index 7010e14ab6..fef9f4e507 100644 --- a/plugins/ActivityPub/Entity/ActivitypubActor.php +++ b/plugins/ActivityPub/Entity/ActivitypubActor.php @@ -186,6 +186,11 @@ class ActivitypubActor extends Entity throw new Exception(_m('Not a valid WebFinger address: ' . $e->getMessage())); } + return self::fromXrd($addr, $xrd); + } + + public static function fromXrd(string $addr, \XML_XRD $xrd): self + { $hints = array_merge( ['webfinger' => $addr], DiscoveryHints::fromXRD($xrd), diff --git a/src/Core/DB/DB.php b/src/Core/DB/DB.php index 70c4650183..0ef5aadc16 100644 --- a/src/Core/DB/DB.php +++ b/src/Core/DB/DB.php @@ -58,7 +58,7 @@ use Functional as F; * @method static void persist(object $entity) // Tells the EntityManager to make an instance managed and persistent. * @method static bool contains(object $entity) // Determines whether an entity instance is managed in this EntityManager. * @method static void flush() // Flushes the in-memory state of persisted objects to the database. - * @method mixed wrapInTransaction(callable $func) // Executes a function in a transaction. + * @method mixed wrapInTransaction(callable $func) // Executes a function in a transaction. Warning: suppresses exceptions */ class DB {