logger = $logger; $this->db = $database; $this->config = $config; $this->pConfig = $pConfig; $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 = []; $abandon_days = intval($this->config->get('system', 'account_abandon_days')); if ($abandon_days < 1) { $abandon_days = 0; } $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400); $pconfigs = $this->db->selectToArray('pconfig', [], ["`cat` = ? AND `k` = ? AND `v`", 'bluesky', 'import']); foreach ($pconfigs as $pconfig) { if (empty($this->getUserDid($pconfig['uid']))) { continue; } if ($abandon_days != 0) { if (!$this->db->exists('user', ["`uid` = ? AND `login_date` >= ?", $pconfig['uid'], $abandon_limit])) { continue; } } $uids[] = $pconfig['uid']; } 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)) { $url .= '?' . http_build_query($parameters); } if ($uid == 0) { return $this->get(ATProtocol::APPVIEW_API . '/xrpc/' . $url); } $pds = $this->getUserPds($uid); if (empty($pds)) { return null; } $headers = ['Authorization' => ['Bearer ' . $this->getUserToken($uid)]]; $languages = User::getWantedLanguages($uid); if (!empty($languages)) { $headers['Accept-Language'] = implode(',', $languages); } $data = $this->get($pds . '/xrpc/' . $url, [HttpClientOptions::HEADERS => $headers]); if (empty($data) || (!empty($data->code) && ($data->code < 200 || $data->code >= 400))) { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_API_FAIL); if (!empty($data->message)) { $this->pConfig->set($uid, 'bluesky', 'status-message', $data->message); } elseif (!empty($data->code)) { $this->pConfig->set($uid, 'bluesky', 'status-message', 'Error Code: ' . $data->code); } } elseif (!empty($data)) { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_SUCCESS); $this->pConfig->set($uid, 'bluesky', 'status-message', ''); } 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 { $curlResult = $this->httpClient->get($url, HttpClientAccept::JSON, $opts); } catch (\Exception $e) { $this->logger->notice('Exception on get', ['url' => $url, 'exception' => $e]); return null; } $data = json_decode($curlResult->getBodyString()); if (!$curlResult->isSuccess()) { $this->logger->notice('API Error', ['url' => $url, 'code' => $curlResult->getReturnCode(), 'error' => $data ?: $curlResult->getBodyString()]); if (!$data) { return null; } $data->code = $curlResult->getReturnCode(); } elseif (($curlResult->getReturnCode() < 200) || ($curlResult->getReturnCode() >= 400)) { $this->logger->notice('Unexpected return code', ['url' => $url, 'code' => $curlResult->getReturnCode(), 'error' => $data ?: $curlResult->getBodyString()]); $data->code = $curlResult->getReturnCode(); } Item::incrementInbound(Protocol::BLUESKY); 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); if (empty($pds)) { return null; } try { $curlResult = $this->httpClient->post($pds . $url, $params, $headers); } catch (\Exception $e) { $this->logger->notice('Exception on post', ['exception' => $e]); $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_API_FAIL); $this->pConfig->set($uid, 'bluesky', 'status-message', $e->getMessage()); return null; } $data = json_decode($curlResult->getBodyString()); if (!$curlResult->isSuccess()) { $this->logger->notice('API Error', ['url' => $url, 'code' => $curlResult->getReturnCode(), 'error' => $data ?: $curlResult->getBodyString()]); if (!$data) { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_API_FAIL); if (!empty($data->message)) { $this->pConfig->set($uid, 'bluesky', 'status-message', $data->message); } elseif (!empty($data->code)) { $this->pConfig->set($uid, 'bluesky', 'status-message', 'Error Code: ' . $data->code); } return null; } $data->code = $curlResult->getReturnCode(); } if (!empty($data->code) && ($data->code >= 200) && ($data->code < 400)) { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_SUCCESS); $this->pConfig->set($uid, 'bluesky', 'status-message', ''); } else { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_API_FAIL); if (!empty($data->message)) { $this->pConfig->set($uid, 'bluesky', 'status-message', $data->message); } elseif (!empty($data->code)) { $this->pConfig->set($uid, 'bluesky', 'status-message', 'Error Code: ' . $data->code); } } 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) { return self::APPVIEW_API; } $pds = $this->pConfig->get($uid, 'bluesky', 'pds'); if (!empty($pds)) { return $pds; } $did = $this->getUserDid($uid); if (empty($did)) { return null; } $pds = $this->getPdsOfDid($did); if (empty($pds)) { return null; } $this->pConfig->set($uid, 'bluesky', 'pds', $pds); 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 (!$refresh) { $did = $this->pConfig->get($uid, 'bluesky', 'did'); if (!empty($did)) { return $did; } } $handle = $this->pConfig->get($uid, 'bluesky', 'handle'); if (empty($handle)) { return null; } $did = $this->getDid($handle); if (empty($did)) { return null; } $this->logger->debug('Got DID for user', ['uid' => $uid, 'handle' => $handle, 'did' => $did]); $this->pConfig->set($uid, 'bluesky', 'did', $did); 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 == '') { return ''; } if (strpos($handle, '.') === false) { $handle .= '.' . self::HOSTNAME; } // At first we use the AppView API which *should* cover all cases. $data = $this->get(self::APPVIEW_API . '/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle)); if (!empty($data) && !empty($data->did)) { $this->logger->debug('Got DID by system PDS call', ['handle' => $handle, 'did' => $data->did]); return $data->did; } // Then we query the DNS, which is used for third party handles (DNS should be faster than wellknown) $did = $this->getDidByDns($handle); if ($did != '') { $this->logger->debug('Got DID by DNS', ['handle' => $handle, 'did' => $did]); return $did; } // Then we query wellknown, which should mostly cover the rest. $did = $this->getDidByWellknown($handle); if ($did != '') { $this->logger->debug('Got DID by wellknown', ['handle' => $handle, 'did' => $did]); return $did; } $this->logger->notice('No DID detected', ['handle' => $handle]); 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)) { $did = $this->getDid($matches[1]); if (!empty($did)) { return $did; } } try { $curlResult = $this->httpClient->get($url, HttpClientAccept::HTML, [HttpClientOptions::REQUEST => HttpClientRequest::CONTACTINFO]); } catch (\Throwable $th) { return ''; } if (!$curlResult->isSuccess()) { return ''; } $profile = $curlResult->getBodyString(); if (empty($profile)) { return ''; } $doc = new DOMDocument(); try { @$doc->loadHTML($profile); } catch (\Throwable $th) { return ''; } $xpath = new DOMXPath($doc); $list = $xpath->query('//p[@id]'); foreach ($list as $node) { foreach ($node->attributes as $attribute) { if ($attribute->name == 'id') { $ids[$attribute->value] = $node->textContent; } } } if (empty($ids['bsky_handle']) || empty($ids['bsky_did'])) { return ''; } if (!$this->isValidDid($ids['bsky_did'], $ids['bsky_handle'])) { $this->logger->notice('Invalid DID', ['handle' => $ids['bsky_handle'], 'did' => $ids['bsky_did']]); return ''; } 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'); if ($curlResult->isSuccess() && substr($curlResult->getBodyString(), 0, 4) == 'did:') { $did = $curlResult->getBodyString(); if (!$this->isValidDid($did, $handle)) { $this->logger->notice('Invalid DID', ['handle' => $handle, 'did' => $did]); return ''; } return $did; } 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); if (empty($records)) { return ''; } foreach ($records as $record) { if (!empty($record['txt']) && substr($record['txt'], 0, 4) == 'did=') { $did = substr($record['txt'], 4); if (!$this->isValidDid($did, $handle)) { $this->logger->notice('Invalid DID', ['handle' => $handle, 'did' => $did]); return ''; } return $did; } } return ''; } /** * 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)) { return null; } foreach ($data->service as $service) { if (($service->id == '#atproto_pds') && ($service->type == 'AtprotoPersonalDataServer') && !empty($service->serviceEndpoint)) { return $service->serviceEndpoint; } } 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); if (empty($data) || empty($data->alsoKnownAs)) { return false; } 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'); $created = $this->pConfig->get($uid, 'bluesky', 'token_created'); if (empty($token)) { return ''; } if ($created + 300 < time()) { return $this->refreshUserToken($uid); } 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'); $data = $this->post($uid, '/xrpc/com.atproto.server.refreshSession', '', ['Authorization' => ['Bearer ' . $token]]); if (empty($data) || empty($data->accessJwt)) { $this->logger->debug('Refresh failed', ['return' => $data]); $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_TOKEN_FAIL); return ''; } $this->logger->debug('Refreshed token', ['return' => $data]); $this->pConfig->set($uid, 'bluesky', 'access_token', $data->accessJwt); $this->pConfig->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt); $this->pConfig->set($uid, 'bluesky', 'token_created', time()); 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); if (empty($did)) { return ''; } $data = $this->post($uid, '/xrpc/com.atproto.server.createSession', json_encode(['identifier' => $did, 'password' => $password]), ['Content-type' => 'application/json']); if (empty($data) || empty($data->accessJwt)) { $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_TOKEN_FAIL); return ''; } $this->logger->debug('Created token', ['return' => $data]); $this->pConfig->set($uid, 'bluesky', 'access_token', $data->accessJwt); $this->pConfig->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt); $this->pConfig->set($uid, 'bluesky', 'token_created', time()); $this->pConfig->set($uid, 'bluesky', 'status', self::STATUS_TOKEN_OK); $this->pConfig->set($uid, 'bluesky', 'status-message', ''); return $data->accessJwt; } }