]> git.mxchange.org Git - friendica.git/commitdiff
Native probe support for AT-Proto
authorMichael <heluecht@pirati.ca>
Sat, 14 Dec 2024 07:14:46 +0000 (07:14 +0000)
committerMichael <heluecht@pirati.ca>
Sat, 14 Dec 2024 15:27:18 +0000 (15:27 +0000)
src/Core/Protocol.php
src/DI.php
src/Network/Probe.php
src/Protocol/ATProtocol.php
src/Protocol/ATProtocol/Actor.php
src/Protocol/ATProtocol/Jetstream.php

index 1294050ab7203f210ad42d742cdff5baf3224a88..e723b13d4509a32610b39d0a4340e1b418e72605 100644 (file)
@@ -302,7 +302,7 @@ class Protocol
                        return false;
                }
 
-               if (in_array($protocol, array_merge(self::NATIVE_SUPPORT, [self::ZOT, self::PHANTOM]))) {
+               if (in_array($protocol, array_merge(self::NATIVE_SUPPORT, [self::ZOT, self::BLUESKY, self::PHANTOM]))) {
                        return true;
                }
 
index 9e1185b5d8a21f9220b2e7675fd29b45d6e60445..d402f9d24bcad8e13ff3afa4c185fb2adb486f87 100644 (file)
@@ -157,7 +157,7 @@ abstract class DI
        }
 
        /**
-        * @return AtProtocol\Arguments
+        * @return ATProtocol\Actor
         */
        public static function atpActor()
        {
index 99fb9ed2112ab2db63b042419e9b53abdc80a0ca..369e303418a8ce8aab4b1ff631aa39f6c0e1b7f4 100644 (file)
@@ -10,6 +10,7 @@ namespace Friendica\Network;
 use DOMDocument;
 use DomXPath;
 use Exception;
+use Friendica\Content\Text\HTML;
 use Friendica\Core\Hook;
 use Friendica\Core\Logger;
 use Friendica\Core\Protocol;
@@ -24,6 +25,7 @@ use Friendica\Network\HTTPClient\Client\HttpClientOptions;
 use Friendica\Network\HTTPClient\Client\HttpClientRequest;
 use Friendica\Protocol\ActivityNamespace;
 use Friendica\Protocol\ActivityPub;
+use Friendica\Protocol\ATProtocol;
 use Friendica\Protocol\Diaspora;
 use Friendica\Protocol\Email;
 use Friendica\Protocol\Feed;
@@ -724,8 +726,8 @@ class Probe
 
                $parts = parse_url($uri);
                if (empty($parts['scheme']) && empty($parts['host']) && (empty($parts['path']) || strpos($parts['path'], '@') === false)) {
-                       Logger::info('URI was not detectable', ['uri' => $uri]);
-                       return [];
+                       Logger::info('URI was not detectable, probe for AT Protocol now', ['uri' => $uri]);
+                       return self::atProtocol($uri);
                }
 
                // If the URI starts with "mailto:" then jump directly to the mail detection
@@ -749,6 +751,10 @@ class Probe
                }
 
                if (empty($data)) {
+                       $data = self::atProtocol($uri);
+                       if (!empty($data)) {
+                               return $data;
+                       }
                        if (!empty($parts['scheme'])) {
                                return self::feed($uri);
                        } elseif (!empty($uid)) {
@@ -1677,6 +1683,75 @@ class Probe
                return (string)Uri::fromParts((array)(array)$baseParts);
        }
 
+       /**
+        * Check for AT Protocol (Bluesky)
+        *
+        * @param string $uri Profile link
+        * @return array Profile data or empty array
+        */
+       private static function atProtocol(string $uri): array
+       {
+               if (parse_url($uri, PHP_URL_SCHEME) == 'did') {
+                       $did = $uri;
+               } elseif (parse_url($uri, PHP_URL_PATH) == $uri && strpos($uri, '@') === false) {
+                       $did = DI::atProtocol()->getDid($uri);
+                       if (empty($did)) {
+                               return [];
+                       }
+               } elseif (Network::isValidHttpUrl($uri)) {
+                       $did = DI::atProtocol()->getDidByProfile($uri);
+                       if (empty($did)) {
+                               return [];
+                       }
+               } else {
+                       return [];
+               }
+       
+               $profile = DI::atProtocol()->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did]);
+               if (empty($profile) || empty($profile->did)) {
+                       return [];
+               }
+
+               $nick = $profile->handle ?? $profile->did;
+               $name = $profile->displayName ?? $nick;
+
+               $data = [
+                       'network'  => Protocol::BLUESKY,
+                       'url'      => $profile->did,
+                       'alias'    => ATProtocol::WEB . '/profile/' . $nick,
+                       'name'     => $name ?: $nick,
+                       'nick'     => $nick,
+                       'addr'     => $nick,
+                       'poll'     => ATProtocol::WEB . '/profile/' . $profile->did . '/rss',
+                       'photo'    => $profile->avatar ?? '',
+               ];
+       
+               if (!empty($profile->description)) {
+                       $data['about'] = HTML::toBBCode($profile->description);
+               }
+       
+               if (!empty($profile->banner)) {
+                       $data['header'] = $profile->banner;
+               }
+
+               $directory = DI::atProtocol()->get(ATProtocol::DIRECTORY . '/' . $profile->did);
+               if (!empty($directory)) {
+                       foreach ($directory->service as $service) {
+                               if (($service->id == '#atproto_pds') && ($service->type == 'AtprotoPersonalDataServer') && !empty($service->serviceEndpoint)) {
+                                       $data['baseurl'] = $service->serviceEndpoint;
+                               }
+                       }
+
+                       foreach ($directory->verificationMethod as $method) {
+                               if (!empty($method->publicKeyMultibase)) {
+                                       $data['pubkey'] = $method->publicKeyMultibase;
+                               }
+                       }
+               }
+
+               return $data;
+       }
+
        /**
         * Check for feed contact
         *
index 72383755fd274f883eb60eed59a520bc51c22122..c4088624eaea8b80499bda0464c5f9dd267ee612 100644 (file)
@@ -66,6 +66,11 @@ final class ATProtocol
                $this->httpClient = $httpClient;
        }
 
+       /**
+        * Returns an array of user ids who want to import the Bluesky timeline
+        *
+        * @return array user ids
+        */
        public function getUids(): array
        {
                $uids         = [];
@@ -92,6 +97,15 @@ final class ATProtocol
                return $uids;
        }
 
+       /**
+        * Fetches XRPC data
+        * @see https://atproto.com/specs/xrpc#lexicon-http-endpoints
+        *
+        * @param string  $url        for example "app.bsky.feed.getTimeline"
+        * @param array   $parameters Array with parameters
+        * @param integer $uid        User ID
+        * @return stdClass|null Fetched data
+        */
        public function XRPCGet(string $url, array $parameters = [], int $uid = 0): ?stdClass
        {
                if (!empty($parameters)) {
@@ -119,6 +133,13 @@ final class ATProtocol
                return $data;
        }
 
+       /**
+        * Fetch data from the given URL via GET and return it as a JSON class
+        *
+        * @param string $url HTTP URL
+        * @param array $opts HTTP options
+        * @return stdClass|null Fetched data
+        */
        public function get(string $url, array $opts = []): ?stdClass
        {
                try {
@@ -141,12 +162,30 @@ final class ATProtocol
                return $data;
        }
 
+       /**
+        * Perform an XRPC post for a given user
+        * @see https://atproto.com/specs/xrpc#lexicon-http-endpoints
+        *
+        * @param integer $uid       User ID
+        * @param string $url        Endpoints like "com.atproto.repo.createRecord"
+        * @param [type] $parameters array or StdClass with parameters
+        * @return stdClass|null
+        */
        public function XRPCPost(int $uid, string $url, $parameters): ?stdClass
        {
                $data = $this->post($uid, '/xrpc/' . $url, json_encode($parameters), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $this->getUserToken($uid)]]);
                return $data;
        }
 
+       /**
+        * Post data to the user PDS
+        *
+        * @param integer $uid   User ID
+        * @param string $url    HTTP URL without the hostname
+        * @param string $params Parameter string
+        * @param array $headers HTTP header information
+        * @return stdClass|null
+        */
        public function post(int $uid, string $url, string $params, array $headers): ?stdClass
        {
                $pds = $this->getUserPds($uid);
@@ -180,6 +219,13 @@ final class ATProtocol
                return $data;
        }
 
+       /**
+        * Fetches the PDS for a given user
+        * @see https://atproto.com/guides/glossary#pds-personal-data-server
+        *
+        * @param integer $uid User ID or 0
+        * @return string|null PDS or null if the user has got no PDS assigned. If UID set to 0, the public api URL is used
+        */
        private function getUserPds(int $uid): ?string
        {
                if ($uid == 0) {
@@ -205,6 +251,14 @@ final class ATProtocol
                return $pds;
        }
 
+       /**
+        * Fetch the DID for a given user
+        * @see https://atproto.com/guides/glossary#did-decentralized-id
+        *
+        * @param integer $uid     User ID
+        * @param boolean $refresh Default "false". If set to true, the DID is detected from the handle again.
+        * @return string|null     DID or null if no DID has been found.
+        */
        public function getUserDid(int $uid, bool $refresh = false): ?string
        {
                if (!$this->pConfig->get($uid, 'bluesky', 'post')) {
@@ -233,6 +287,12 @@ final class ATProtocol
                return $did;
        }
 
+       /**
+        * Fetches the DID for a given handle
+        *
+        * @param string $handle The user handle
+        * @return string DID (did:plc:...)
+        */
        public function getDid(string $handle): string
        {
                if ($handle == '') {
@@ -268,6 +328,12 @@ final class ATProtocol
                return '';
        }
 
+       /**
+        * Fetches a DID for a given profile URL
+        *
+        * @param string $url HTTP path to the profile in the format https://bsky.app/profile/username
+        * @return string DID (did:plc:...)
+        */
        public function getDidByProfile(string $url): string
        {
                if (preg_match('#^' . self::WEB . '/profile/(.+)#', $url, $matches)) {
@@ -317,6 +383,13 @@ final class ATProtocol
                return $ids['bsky_did'];
        }
 
+       /**
+        * Fetches the DID of a given handle via a HTTP request to the .well-known URL.
+        * This is one of the ways, custom handles can be authorized.
+        *
+        * @param string $handle The user handle
+        * @return string DID (did:plc:...)
+        */
        private function getDidByWellknown(string $handle): string
        {
                $curlResult = $this->httpClient->get('http://' . $handle . '/.well-known/atproto-did');
@@ -331,6 +404,13 @@ final class ATProtocol
                return '';
        }
 
+       /**
+        * Fetches the DID of a given handle via a DND request.
+        * This is one of the ways, custom handles can be authorized.
+        *
+        * @param string $handle The user handle
+        * @return string DID (did:plc:...)
+        */
        private function getDidByDns(string $handle): string
        {
                $records = @dns_get_record('_atproto.' . $handle . '.', DNS_TXT);
@@ -350,7 +430,13 @@ final class ATProtocol
                return '';
        }
 
-       private function getPdsOfDid(string $did): ?string
+       /**
+        * Fetch the PDS of a given DID
+        *
+        * @param string $did DID (did:plc:...)
+        * @return string|null URL of the PDS, e.g. https://enoki.us-east.host.bsky.network
+        */
+       public function getPdsOfDid(string $did): ?string
        {
                $data = $this->get(self::DIRECTORY . '/' . $did);
                if (empty($data) || empty($data->service)) {
@@ -366,6 +452,13 @@ final class ATProtocol
                return null;
        }
 
+       /**
+        * Checks if the provided DID matches the handle
+        *
+        * @param string $did DID (did:plc:...)
+        * @param string $handle The user handle
+        * @return boolean
+        */
        private function isValidDid(string $did, string $handle): bool
        {
                $data = $this->get(self::DIRECTORY . '/' . $did);
@@ -376,6 +469,12 @@ final class ATProtocol
                return in_array('at://' . $handle, $data->alsoKnownAs);
        }
 
+       /**
+        * Fetches the user token for a given user
+        *
+        * @param integer $uid User ID
+        * @return string user token
+        */
        public function getUserToken(int $uid): string
        {
                $token   = $this->pConfig->get($uid, 'bluesky', 'access_token');
@@ -390,6 +489,12 @@ final class ATProtocol
                return $token;
        }
 
+       /**
+        * Refresh and returns the user token for a given user.
+        *
+        * @param integer $uid User ID
+        * @return string user token
+        */
        private function refreshUserToken(int $uid): string
        {
                $token = $this->pConfig->get($uid, 'bluesky', 'refresh_token');
@@ -412,6 +517,13 @@ final class ATProtocol
                return $data->accessJwt;
        }
 
+       /**
+        * Create a user token for the given user
+        *
+        * @param integer $uid      User ID
+        * @param string  $password Application password
+        * @return string user token
+        */
        public function createUserToken(int $uid, string $password): string
        {
                $did = $this->getUserDid($uid);
index e3fdd7e185d7404ea576e116ee38a7b2e416f3d2..a45bdd297829f9319ef02f873c46d161d1351a2d 100755 (executable)
@@ -35,7 +35,13 @@ class Actor
                $this->atprotocol = $atprotocol;
        }
 
-       public function syncContacts(int $uid)
+       /**
+        * Syncronize the contacts (followers, sharers) for the given user
+        *
+        * @param integer $uid User ID
+        * @return void
+        */
+       public function syncContacts(int $uid): void
        {
                $this->logger->info('Sync contacts for user - start', ['uid' => $uid]);
                $contacts = Contact::selectToArray(['id', 'url', 'rel'], ['uid' => $uid, 'network' => Protocol::BLUESKY, 'rel' => [Contact::FRIEND, Contact::SHARING, Contact::FOLLOWER]]);
@@ -93,9 +99,16 @@ class Actor
                $this->logger->info('Sync contacts for user - done', ['uid' => $uid]);
        }
 
-       public function updateContactByDID(string $did)
+       /**
+        * Update a contact for a given DID and user id
+        *
+        * @param string  $did         DID (did:plc:...)
+        * @param integer $contact_uid User id of the contact to be updated
+        * @return void
+        */
+       public function updateContactByDID(string $did, int $contact_uid): void
        {
-               $profile = $this->atprotocol->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did]);
+               $profile = $this->atprotocol->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did], $contact_uid);
                if (empty($profile) || empty($profile->did)) {
                        return;
                }
@@ -139,20 +152,7 @@ class Actor
                        }
                }
 
-               /*
-               @todo Add this part when the function will be callable with a uid
-               if (!empty($profile->viewer)) {
-                       if (!empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
-                               $fields['rel'] = Contact::FRIEND;
-                       } elseif (!empty($profile->viewer->following) && empty($profile->viewer->followedBy)) {
-                               $fields['rel'] = Contact::SHARING;
-                       } elseif (empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
-                               $fields['rel'] = Contact::FOLLOWER;
-                       } else {
-                               $fields['rel'] = Contact::NOTHING;
-                       }
-               }
-               */
+               Contact::update($fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY]);
 
                if (!empty($profile->avatar)) {
                        $contact = Contact::selectFirst(['id', 'avatar'], ['network' => Protocol::BLUESKY, 'nurl' => $did, 'uid' => 0]);
@@ -161,16 +161,37 @@ class Actor
                        }
                }
 
-               $this->logger->notice('Update profile', ['did' => $profile->did, 'fields' => $fields]);
+               $this->logger->notice('Update global profile', ['did' => $profile->did, 'fields' => $fields]);
 
-               Contact::update($fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY]);
+               if (!empty($profile->viewer) && ($contact_uid != 0)) {
+                       if (!empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
+                               $user_fields = ['rel' => Contact::FRIEND];
+                       } elseif (!empty($profile->viewer->following) && empty($profile->viewer->followedBy)) {
+                               $user_fields = ['rel' => Contact::SHARING];
+                       } elseif (empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
+                               $user_fields = ['rel' => Contact::FOLLOWER];
+                       } else {
+                               $user_fields = ['rel' => Contact::NOTHING];
+                       }
+                       Contact::update($user_fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY, 'uid' => $contact_uid]);
+                       $this->logger->notice('Update user profile', ['uid' => $contact_uid, 'did' => $profile->did, 'fields' => $user_fields]);
+               }
        }
 
-       public function getContactByDID(string $did, int $uid, int $contact_uid): array
+       /**
+        * Fetch and possibly create a contact array for a given DID
+        *
+        * @param string  $did         The contact DID
+        * @param integer $uid         "0" when either the public contact or the user contact is desired
+        * @param integer $contact_uid If not found, the contact will be created for this user id
+        * @param boolean $auto_update Default "false". If activated, the contact will be updated every 24 hours
+        * @return array Contact array
+        */
+       public function getContactByDID(string $did, int $uid, int $contact_uid, bool $auto_update = false): array
        {
                $contact = Contact::selectFirst([], ['network' => Protocol::BLUESKY, 'nurl' => $did, 'uid' => [$contact_uid, $uid]], ['order' => ['uid' => true]]);
 
-               if (!empty($contact)) {
+               if (!empty($contact) && (!$auto_update || ($contact['updated'] > DateTimeFormat::utc('now -24 hours')))) {
                        return $contact;
                }
 
@@ -196,7 +217,7 @@ class Actor
                        $cid = $contact['id'];
                }
 
-               $this->updateContactByDID($did);
+               $this->updateContactByDID($did, $contact_uid);
 
                return Contact::getById($cid);
        }
index bf53aa46747c6be488ae7c882e79578debb83410..78b84f27fb55313fdd43ef4cbdc9e87f1678d4c0 100755 (executable)
@@ -41,7 +41,6 @@ class Jetstream
        private $uids      = [];
        private $self      = [];
        private $capped    = false;
-       private $next_stat = 0;
 
        /** @var LoggerInterface */
        private $logger;
@@ -74,10 +73,12 @@ class Jetstream
                $this->processor  = $processor;
        }
 
-       // *****************************************
-       // * Listener
-       // *****************************************
-       public function listen()
+       /**
+        * Listen to incoming webstream messages from Jetstream
+        *
+        * @return void
+        */
+       public function listen(): void
        {
                $timeout       = 300;
                $timeout_limit = 10;
@@ -137,7 +138,12 @@ class Jetstream
                }
        }
 
-       private function incrementMessages()
+       /**
+        * Increment the message counter for the statistics page
+        *
+        * @return void
+        */
+       private function incrementMessages(): void
        {
                $packets = (int)($this->keyValue->get('jetstream_messages') ?? 0);
                if ($packets >= PHP_INT_MAX) {
@@ -146,6 +152,11 @@ class Jetstream
                $this->keyValue->set('jetstream_messages', $packets + 1);
        }
 
+       /**
+        * Synchronize contacts for all active users
+        *
+        * @return void
+        */
        private function syncContacts()
        {
                $active_uids = $this->atprotocol->getUids();
@@ -158,6 +169,11 @@ class Jetstream
                }
        }
 
+       /**
+        * Set options like the followed DIDs
+        *
+        * @return void
+        */
        private function setOptions()
        {
                $active_uids = $this->atprotocol->getUids();
@@ -219,6 +235,15 @@ class Jetstream
                }
        }
 
+       /**
+        * Returns an array of DIDs provided by an array of contacts
+        *
+        * @param array   $contacts  Array of contact records
+        * @param array   $uids      Array with the user ids with enabled bluesky timeline import
+        * @param integer $did_limit Maximum limit of entries
+        * @param array   $dids      Array of DIDs that are added to the output list
+        * @return array DIDs
+        */
        private function addDids(array $contacts, array $uids, int $did_limit, array $dids): array
        {
                foreach ($contacts as $contact) {
@@ -233,7 +258,13 @@ class Jetstream
                return $dids;
        }
 
-       private function route(stdClass $data)
+       /**
+        * Route incoming messages
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function route(stdClass $data): void
        {
                Item::incrementInbound(Protocol::BLUESKY);
 
@@ -254,7 +285,13 @@ class Jetstream
                }
        }
 
-       private function routeCommits(stdClass $data)
+       /**
+        * Route incoming commit messages
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function routeCommits(stdClass $data): void
        {
                $drift = $this->getDrift($data);
                $this->logger->notice('Received commit', ['time' => date(DateTimeFormat::ATOM, $data->time_us / 1000000), 'drift' => $drift, 'capped' => $this->capped, 'did' => $data->did, 'operation' => $data->commit->operation, 'collection' => $data->commit->collection, 'timestamp' => $data->time_us]);
@@ -304,6 +341,12 @@ class Jetstream
                }
        }
 
+       /**
+        * Calculate the drift between the server timestamp and the current time.
+        *
+        * @param stdClass $data message object
+        * @return integer The calculated drift
+        */
        private function getDrift(stdClass $data): int
        {
                $drift = max(0, round(time() - $data->time_us / 1000000));
@@ -321,7 +364,14 @@ class Jetstream
                return $drift;
        }
 
-       private function routePost(stdClass $data, int $drift)
+       /**
+        * Route app.bsky.feed.post commits 
+        *
+        * @param stdClass $data message object
+        * @param integer $drift
+        * @return void
+        */
+       private function routePost(stdClass $data, int $drift): void
        {
                switch ($data->commit->operation) {
                        case 'delete':
@@ -338,7 +388,14 @@ class Jetstream
                }
        }
 
-       private function routeRepost(stdClass $data, int $drift)
+       /**
+        * Route app.bsky.feed.repost commits 
+        *
+        * @param stdClass $data message object
+        * @param integer $drift
+        * @return void
+        */
+       private function routeRepost(stdClass $data, int $drift): void
        {
                switch ($data->commit->operation) {
                        case 'delete':
@@ -355,7 +412,13 @@ class Jetstream
                }
        }
 
-       private function routeLike(stdClass $data)
+       /**
+        * Route app.bsky.feed.like commits 
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function routeLike(stdClass $data): void
        {
                switch ($data->commit->operation) {
                        case 'delete':
@@ -372,7 +435,13 @@ class Jetstream
                }
        }
 
-       private function routeProfile(stdClass $data)
+       /**
+        * Route app.bsky.actor.profile commits 
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function routeProfile(stdClass $data): void
        {
                switch ($data->commit->operation) {
                        case 'delete':
@@ -380,11 +449,11 @@ class Jetstream
                                break;
 
                        case 'create':
-                               $this->actor->updateContactByDID($data->did);
+                               $this->actor->updateContactByDID($data->did, 0);
                                break;
 
                        case 'update':
-                               $this->actor->updateContactByDID($data->did);
+                               $this->actor->updateContactByDID($data->did, 0);
                                break;
 
                        default:
@@ -393,7 +462,13 @@ class Jetstream
                }
        }
 
-       private function routeFollow(stdClass $data)
+       /**
+        * Route app.bsky.graph.follow commits 
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function routeFollow(stdClass $data): void
        {
                switch ($data->commit->operation) {
                        case 'delete':
@@ -416,7 +491,13 @@ class Jetstream
                }
        }
 
-       private function storeCommitMessage(stdClass $data)
+       /**
+        * Store commit messages for debugging purposes
+        *
+        * @param stdClass $data message object
+        * @return void
+        */
+       private function storeCommitMessage(stdClass $data): void
        {
                if ($this->config->get('debug', 'jetstream_log')) {
                        $tempfile = tempnam(System::getTempPath(), 'at-proto.commit.' . $data->commit->collection . '.' . $data->commit->operation . '-');