From c862c9bf18e427f49fd2b58297d10fac29ab104a Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Mon, 1 Nov 2021 12:16:46 +0000 Subject: [PATCH] [ActivityPub] Make remote mentions great again --- components/FreeNetwork/FreeNetwork.php | 2 +- plugins/ActivityPub/ActivityPub.php | 213 ++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 7 deletions(-) diff --git a/components/FreeNetwork/FreeNetwork.php b/components/FreeNetwork/FreeNetwork.php index 45c119ab2a..5831006c77 100644 --- a/components/FreeNetwork/FreeNetwork.php +++ b/components/FreeNetwork/FreeNetwork.php @@ -125,7 +125,7 @@ class FreeNetwork extends Component } } else { try { - if (filter_var($resource, \FILTER_VALIDATE_URL) !== false) { + if (Common::isValidHttpUrl($resource)) { // This means $resource is a valid url $resource_parts = parse_url($resource); // TODO: Use URLMatcher diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index d4a9c853ad..2504dee8c6 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -5,21 +5,32 @@ declare(strict_types = 1); namespace Plugin\ActivityPub; use App\Core\Event; +use App\Core\Log; use App\Core\Modules\Plugin; use App\Core\Router\RouteLoader; use App\Core\Router\Router; use App\Entity\Actor; use App\Entity\LocalUser; +use App\Util\Common; +use App\Util\Exception\NicknameEmptyException; +use App\Util\Exception\NicknameException; +use App\Util\Exception\NicknameInvalidException; +use App\Util\Exception\NicknameNotAllowedException; +use App\Util\Exception\NicknameTakenException; +use App\Util\Exception\NicknameTooLongException; use App\Util\Exception\NoSuchActorException; use App\Util\Nickname; use Exception; use Plugin\ActivityPub\Controller\Inbox; use Plugin\ActivityPub\Entity\ActivitypubActor; +use Plugin\ActivityPub\Util\Explorer; use Plugin\ActivityPub\Util\Response\ActorResponse; use Plugin\ActivityPub\Util\Response\NoteResponse; use Plugin\ActivityPub\Util\Response\TypeResponse; use XML_XRD; use XML_XRD_Element_Link; +use function count; +use const PREG_SET_ORDER; class ActivityPub extends Plugin { @@ -78,7 +89,7 @@ class ActivityPub extends Plugin public static function getActorByUri(string $resource, ?bool $attempt_fetch = true): Actor { // Try local - if (filter_var($resource, \FILTER_VALIDATE_URL) !== false) { + if (Common::isValidHttpUrl($resource)) { // This means $resource is a valid url $resource_parts = parse_url($resource); // TODO: Use URLMatcher @@ -88,9 +99,9 @@ class ActivityPub extends Plugin $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m'; // actor_view_id $reuri = '/\/actor\/(\d+)\/?/m'; - if (preg_match_all($renick, $str, $matches, \PREG_SET_ORDER, 0) === 1) { + if (preg_match_all($renick, $str, $matches, PREG_SET_ORDER, 0) === 1) { return LocalUser::getWithPK(['nickname' => $matches[0][1]])->getActor(); - } elseif (preg_match_all($reuri, $str, $matches, \PREG_SET_ORDER, 0) === 1) { + } elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) { return Actor::getById((int) $matches[0][1]); } } @@ -109,7 +120,7 @@ class ActivityPub extends Plugin */ public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool { - if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) { + if (count(array_intersect(self::$accept_headers, $accept_header)) === 0) { return Event::next; } switch ($route) { @@ -140,6 +151,10 @@ class ActivityPub extends Plugin } } + /******************************************************** + * WebFinger Events * + ********************************************************/ + /** * Add activity+json mimetype on WebFinger * @@ -158,9 +173,195 @@ class ActivityPub extends Plugin return Event::next; } - public function onFreeNetworkGenerateLocalActorUri(int $actor_id, ?array &$actor_uri): bool + /** + * Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz + * + * @param string $text The text from which to extract webfinger IDs + * @param string $preMention Character(s) that signals a mention ('@', '!'...) + * @return array The matching IDs (without $preMention) and each respective position in the given string. + */ + public static function extractWebfingerIds(string $text, string $preMention='@'): array { - $actor_uri['ActivityPub'] = Router::url('actor_view_id', ['id' => $actor_id], Router::ABSOLUTE_URL); + $wmatches = []; + $result = preg_match_all( + '/'.Nickname::BEFORE_MENTIONS.preg_quote($preMention, '/').'('.Nickname::WEBFINGER_FMT.')/', + $text, + $wmatches, + PREG_OFFSET_CAPTURE + ); + if ($result === false) { + Log::error(__METHOD__ . ': Error parsing webfinger IDs from text (preg_last_error=='.preg_last_error().').'); + return []; + } elseif (($n_matches = count($wmatches)) != 0) { + Log::debug((sprintf('Found %d matches for WebFinger IDs: %s', $n_matches, print_r($wmatches, true)))); + } + return $wmatches[1]; + } + + /** + * Profile URL matches: @example.com/mublog/user + * + * @param string $text The text from which to extract URL mentions + * @param string $preMention Character(s) that signals a mention ('@', '!'...) + * @return array The matching URLs (without @ or acct:) and each respective position in the given string. + */ + public static function extractUrlMentions(string $text, string $preMention='@'): array + { + $wmatches = []; + // 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.']*)*)/', + $text, + $wmatches, + PREG_OFFSET_CAPTURE + ); + if ($result === false) { + Log::error(__METHOD__ . ': Error parsing profile URL mentions from text (preg_last_error=='.preg_last_error().').'); + return []; + } elseif (count($wmatches)) { + Log::debug((sprintf('Found %d matches for profile URL mentions: %s', count($wmatches), print_r($wmatches, true)))); + } + return $wmatches[1]; + } + + /** + * Find any explicit remote mentions. Accepted forms: + * Webfinger: @user@example.com + * Profile link: @example.com/mublog/user + * @param Actor $sender + * @param string $text input markup text + * @param $mentions + * @return bool hook return value + */ + public function onEndFindMentions(Actor $sender, string $text, array &$mentions): bool + { + $matches = []; + + foreach (self::extractWebfingerIds($text, '@') as $wmatch) { + list($target, $pos) = $wmatch; + Log::info("Checking webfinger person '$target'"); + $profile = null; + try { + $aprofile = ActivitypubActor::getByAddr($target); + $profile = Actor::getById($aprofile->getActorId()); + } catch (Exception $e) { + Log::error("Webfinger check failed: " . $e->getMessage()); + continue; + } + assert($profile instanceof Actor); + + $displayName = $profile->getFullname() ?? $profile->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here + + $matches[$pos] = [ + 'mentioned' => [$profile], + 'type' => 'mention', + 'text' => $displayName, + 'position' => $pos, + 'length' => mb_strlen($target), + 'url' => $aprofile->getUri() + ]; + } + + foreach (self::extractUrlMentions($text) as $wmatch) { + list($target, $pos) = $wmatch; + $schemes = array('https', 'http'); + foreach ($schemes as $scheme) { + $url = "$scheme://$target"; + Log::info("Checking profile address '$url'"); + try { + $aprofile = ActivitypubActor::fromUri($url); + $profile = Actor::getById($aprofile->getActorId()); + $displayName = $profile->getFullname() ?? $profile->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here + $matches[$pos] = ['mentioned' => [$profile], + 'type' => 'mention', + 'text' => $displayName, + 'position' => $pos, + 'length' => mb_strlen($target), + 'url' => $aprofile->getUri() + ]; + break; + } catch (Exception $e) { + Log::error("Profile check failed: " . $e->getMessage()); + } + } + } + + foreach ($mentions as $i => $other) { + // If we share a common prefix with a local user, override it! + $pos = $other['position']; + if (isset($matches[$pos])) { + $mentions[$i] = $matches[$pos]; + unset($matches[$pos]); + } + } + foreach ($matches as $mention) { + $mentions[] = $mention; + } + return Event::next; } + + /** + * Allow remote profile references to be used in commands: + * sub update@status.net + * whois evan@identi.ca + * reply http://identi.ca/evan hey what's up + * + * @param Command $command + * @param string $arg + * @param Actor &$profile + * @return bool hook return code + * @author GNU social + */ + //public function onStartCommandGetProfile($command, $arg, &$profile) + //{ + // $aprofile = ActivitypubActor::fromUri($arg); + // if (!($aprofile instanceof ActivitypubActor)) { + // // No remote ActivityPub profile found + // return Event::next; + // } + // + // return Event::stop; + //} + + /******************************************************** + * Discovery Events * + ********************************************************/ + + /** + * Profile from URI. + * + * @author GNU social + * @param string $uri + * @param Actor &$profile in/out param: Profile got from URI + * @return mixed hook return code + */ + //public function onStartGetProfileFromURI($uri, &$profile) + //{ + // try { + // $profile = Explorer::get_profile_from_url($uri); + // return Event::stop; + // } catch (Exception) { + // return Event::next; // It's not an ActivityPub profile as far as we know, continue event handling + // } + //} + + /** + * Try to grab and store the remote profile by the given uri + * + * @param string $uri + * @param Actor|null &$profile + * @return bool + */ + //public function onRemoteFollowPullProfile(string $uri, ?Actor &$profile): bool + //{ + // $aprofile = ActivitypubActor::fromUri($uri); + // if (!($aprofile instanceof ActivitypubActor)) { + // // No remote ActivityPub profile found + // return Event::next; + // } + // + // return is_null($profile) ? Event::next : Event::stop; + //} }