friendica-github/src/Protocol/ATProtocol.php
2024-12-28 20:40:53 +00:00

565 lines
17 KiB
PHP

<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace Friendica\Protocol;
use DOMDocument;
use DOMXPath;
use Friendica\Core\Config\Capability\IManageConfigValues;
use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues;
use Friendica\Core\Protocol;
use Friendica\Database\Database;
use Friendica\Model\Item;
use Friendica\Model\User;
use Friendica\Network\HTTPClient\Capability\ICanSendHttpRequests;
use Friendica\Network\HTTPClient\Client\HttpClientAccept;
use Friendica\Network\HTTPClient\Client\HttpClientOptions;
use Friendica\Network\HTTPClient\Client\HttpClientRequest;
use Friendica\Util\DateTimeFormat;
use Psr\Log\LoggerInterface;
use stdClass;
/**
* Base class for the ATProtocol
* @see https://atproto.com/
*/
final class ATProtocol
{
const STATUS_UNKNOWN = 0;
const STATUS_TOKEN_OK = 1;
const STATUS_SUCCESS = 2;
const STATUS_API_FAIL = 10;
const STATUS_DID_FAIL = 11;
const STATUS_PDS_FAIL = 12;
const STATUS_TOKEN_FAIL = 13;
const APPVIEW_API = 'https://public.api.bsky.app'; // Path to the public Bluesky AppView API.
const DIRECTORY = 'https://plc.directory'; // Path to the directory server service to fetch the PDS of a given DID
const WEB = 'https://bsky.app'; // Path to the web interface with the user profile and posts
const HOSTNAME = 'bsky.social'; // Host name to be added to the handle if incomplete
/** @var LoggerInterface */
private $logger;
/** @var Database */
private $db;
/** @var \Friendica\Core\Config\Capability\IManageConfigValues */
private $config;
/** @var IManagePersonalConfigValue */
private $pConfig;
/** @var ICanSendHttpRequests */
private $httpClient;
public function __construct(LoggerInterface $logger, Database $database, IManageConfigValues $config, IManagePersonalConfigValues $pConfig, ICanSendHttpRequests $httpClient)
{
$this->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;
}
}