diff --git a/src/Core/Protocol.php b/src/Core/Protocol.php index 1294050ab7..e723b13d45 100644 --- a/src/Core/Protocol.php +++ b/src/Core/Protocol.php @@ -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; } diff --git a/src/DI.php b/src/DI.php index 9e1185b5d8..d402f9d24b 100644 --- a/src/DI.php +++ b/src/DI.php @@ -157,7 +157,7 @@ abstract class DI } /** - * @return AtProtocol\Arguments + * @return ATProtocol\Actor */ public static function atpActor() { diff --git a/src/Network/Probe.php b/src/Network/Probe.php index 99fb9ed211..369e303418 100644 --- a/src/Network/Probe.php +++ b/src/Network/Probe.php @@ -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 * diff --git a/src/Protocol/ATProtocol.php b/src/Protocol/ATProtocol.php index 72383755fd..c4088624ea 100644 --- a/src/Protocol/ATProtocol.php +++ b/src/Protocol/ATProtocol.php @@ -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); diff --git a/src/Protocol/ATProtocol/Actor.php b/src/Protocol/ATProtocol/Actor.php index e3fdd7e185..a45bdd2978 100755 --- a/src/Protocol/ATProtocol/Actor.php +++ b/src/Protocol/ATProtocol/Actor.php @@ -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); } diff --git a/src/Protocol/ATProtocol/Jetstream.php b/src/Protocol/ATProtocol/Jetstream.php index bf53aa4674..78b84f27fb 100755 --- a/src/Protocol/ATProtocol/Jetstream.php +++ b/src/Protocol/ATProtocol/Jetstream.php @@ -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 . '-');