diff --git a/doc/API-Mastodon.md b/doc/API-Mastodon.md index a711e367fb..546aca2431 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -16,11 +16,18 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/ap ## Implemented endpoints - [GET /api/v1/follow_requests](https://docs.joinmastodon.org/api/rest/follow-requests/#get-api-v1-follow-requests) +- [POST /api/v1/follow_requests/:id/authorize](https://docs.joinmastodon.org/api/rest/follow-requests/#post-api-v1-follow-requests-id-authorize) + - Returns a [Relationship](https://docs.joinmastodon.org/api/entities/#relationship) object. +- [POST /api/v1/follow_requests/:id/reject](https://docs.joinmastodon.org/api/rest/follow-requests/#post-api-v1-follow-requests-id-reject) + - Returns a [Relationship](https://docs.joinmastodon.org/api/entities/#relationship) object. +- POST /api/v1/follow_requests/:id/ignore + - Friendica-specific, hides the follow request from the list and prevents the remote contact from retrying. + - Returns a [Relationship](https://docs.joinmastodon.org/api/entities/#relationship) object. + + - [GET /api/v1/instance](https://docs.joinmastodon.org/api/rest/instances) - GET /api/v1/instance/peers - undocumented, but implemented by Mastodon and Pleroma + + ## Non-implemented endpoints - -- [POST /api/v1/follow_requests/:id/authorize](https://docs.joinmastodon.org/api/rest/follow-requests/#post-api-v1-follow-requests-id-authorize) -- [POST /api/v1/follow_requests/:id/reject](https://docs.joinmastodon.org/api/rest/follow-requests/#post-api-v1-follow-requests-id-reject) - diff --git a/mod/notifications.php b/mod/notifications.php index 88972728cb..90036705bc 100644 --- a/mod/notifications.php +++ b/mod/notifications.php @@ -14,7 +14,7 @@ use Friendica\Core\Renderer; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\Module\Login; -use Friendica\Model\Contact; +use Friendica\Model\Introduction; use Friendica\Model\Notify; function notifications_post(App $a) @@ -30,43 +30,20 @@ function notifications_post(App $a) } if ($request_id) { - $intro = DBA::selectFirst('intro', ['id', 'contact-id', 'fid'], ['id' => $request_id, 'uid' => local_user()]); + /** @var Introduction $Intro */ + $Intro = \Friendica\BaseObject::getClass(Introduction::class); + $Intro->fetch(['id' => $request_id, 'uid' => local_user()]); - if (DBA::isResult($intro)) { - $intro_id = $intro['id']; - $contact_id = $intro['contact-id']; - } else { - notice(L10n::t('Invalid request identifier.') . EOL); - return; + switch ($_POST['submit']) { + case L10n::t('Discard'): + $Intro->discard(); + break; + case L10n::t('Ignore'): + $Intro->ignore(); + break; } - // If it is a friend suggestion, the contact is not a new friend but an existing friend - // that should not be deleted. - - $fid = $intro['fid']; - - if ($_POST['submit'] == L10n::t('Discard')) { - DBA::delete('intro', ['id' => $intro_id]); - if (!$fid) { - // When the contact entry had been created just for that intro, we want to get rid of it now - $condition = ['id' => $contact_id, 'uid' => local_user(), - 'self' => false, 'pending' => true, 'rel' => [0, Contact::FOLLOWER]]; - $contact_pending = DBA::exists('contact', $condition); - - // Remove the "pending" to stop the reappearing in any case - DBA::update('contact', ['pending' => false], ['id' => $contact_id]); - - if ($contact_pending) { - Contact::remove($contact_id); - } - } - $a->internalRedirect('notifications/intros'); - } - - if ($_POST['submit'] == L10n::t('Ignore')) { - DBA::update('intro', ['ignore' => true], ['id' => $intro_id]); - $a->internalRedirect('notifications/intros'); - } + $a->internalRedirect('notifications/intros'); } } diff --git a/src/Api/Mastodon/Account.php b/src/Api/Mastodon/Account.php index 5d4f369155..18ab93be0c 100644 --- a/src/Api/Mastodon/Account.php +++ b/src/Api/Mastodon/Account.php @@ -4,6 +4,7 @@ namespace Friendica\Api\Mastodon; use Friendica\Content\Text\BBCode; use Friendica\Database\DBA; +use Friendica\Model\Contact; use Friendica\Util\DateTimeFormat; /** @@ -55,31 +56,33 @@ class Account /** * Creates an account record from a contact record. Expects all contact table fields to be set * - * @param array $contact + * @param array $contact Full contact table record + * @param array $apcontact Full apcontact table record * @return Account * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function createFromContact(array $contact) { + public static function createFromContact(array $contact, array $apcontact = []) + { $account = new Account(); - $account->id = $contact['id']; - $account->username = $contact['nick']; - $account->acct = $contact['nick']; - $account->display_name = $contact['name']; - $account->locked = $contact['blocked']; - $account->created_at = DateTimeFormat::utc($contact['created'], DateTimeFormat::ATOM); - // No data is available from contact - $account->followers_count = 0; - $account->following_count = 0; - $account->statuses_count = 0; - $account->note = BBCode::convert($contact['about']); - $account->url = $contact['url']; - $account->avatar = $contact['avatar']; - $account->avatar_static = $contact['avatar']; + $account->id = $contact['id']; + $account->username = $contact['nick']; + $account->acct = $contact['nick']; + $account->display_name = $contact['name']; + $account->locked = !empty($apcontact['manually-approve']); + $account->created_at = DateTimeFormat::utc($contact['created'], DateTimeFormat::ATOM); + $account->followers_count = $apcontact['followers_count'] ?? 0; + $account->following_count = $apcontact['following_count'] ?? 0; + $account->statuses_count = $apcontact['statuses_count'] ?? 0; + $account->note = BBCode::convert($contact['about'], false); + $account->url = $contact['url']; + $account->avatar = $contact['avatar']; + $account->avatar_static = $contact['avatar']; // No header picture in Friendica - $account->header = ''; - $account->header_static = ''; + $account->header = ''; + $account->header_static = ''; // No custom emojis per account in Friendica - $account->emojis = []; + $account->emojis = []; + $account->bot = ($contact['contact-type'] == Contact::TYPE_NEWS); return $account; } diff --git a/src/Api/Mastodon/Relationship.php b/src/Api/Mastodon/Relationship.php new file mode 100644 index 0000000000..18e249ba3e --- /dev/null +++ b/src/Api/Mastodon/Relationship.php @@ -0,0 +1,59 @@ +id = $contact['id']; + $relationship->following = in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND]); + $relationship->followed_by = in_array($contact['rel'], [Contact::FOLLOWER, Contact::FRIEND]); + $relationship->blocking = (bool)$contact['blocked']; + $relationship->muting = (bool)$contact['readonly']; + $relationship->muting_notifications = (bool)$contact['readonly']; + $relationship->requested = (bool)$contact['pending']; + $relationship->domain_blocking = Network::isUrlBlocked($contact['url']); + // Unsupported + $relationship->showing_reblogs = true; + // Unsupported + $relationship->endorsed = false; + + return $relationship; + } +} diff --git a/src/App/Module.php b/src/App/Module.php index 868520c025..a8648d0aa0 100644 --- a/src/App/Module.php +++ b/src/App/Module.php @@ -251,10 +251,6 @@ class Module call_user_func([$this->module_class, 'init'], $this->module_parameters); - // "rawContent" is especially meant for technical endpoints. - // This endpoint doesn't need any theme initialization or other comparable stuff. - call_user_func([$this->module_class, 'rawContent'], $this->module_parameters); - if ($server['REQUEST_METHOD'] === 'POST') { Core\Hook::callAll($this->module . '_mod_post', $post); call_user_func([$this->module_class, 'post'], $this->module_parameters); @@ -262,5 +258,9 @@ class Module Core\Hook::callAll($this->module . '_mod_afterpost', $placeholder); call_user_func([$this->module_class, 'afterpost'], $this->module_parameters); + + // "rawContent" is especially meant for technical endpoints. + // This endpoint doesn't need any theme initialization or other comparable stuff. + call_user_func([$this->module_class, 'rawContent'], $this->module_parameters); } } diff --git a/src/BaseModel.php b/src/BaseModel.php new file mode 100644 index 0000000000..32011c7db2 --- /dev/null +++ b/src/BaseModel.php @@ -0,0 +1,95 @@ +dba = $dba; + $this->logger = $logger; + } + + /** + * Magic getter. This allows to retrieve model fields with the following syntax: + * - $model->field (outside of class) + * - $this->field (inside of class) + * + * @param $name + * @return mixed + * @throws HTTPException\InternalServerErrorException + */ + public function __get($name) + { + if (empty($this->data['id'])) { + throw new HTTPException\InternalServerErrorException(static::class . ' record uninitialized'); + } + + if (!array_key_exists($name, $this->data)) { + throw new HTTPException\InternalServerErrorException('Field ' . $name . ' not found in ' . static::class); + } + + return $this->data[$name]; + } + + /** + * Fetches a single model record. The condition array is expected to contain a unique index (primary or otherwise). + * + * Chainable. + * + * @param array $condition + * @return BaseModel + * @throws HTTPException\NotFoundException + */ + public function fetch(array $condition) + { + $intro = $this->dba->selectFirst(static::$table_name, [], $condition); + + if (!$intro) { + throw new HTTPException\NotFoundException(static::class . ' record not found.'); + } + + $this->data = $intro; + + return $this; + } + + /** + * Deletes the model record from the database. + * Prevents further methods from being called by wiping the internal model data. + */ + public function delete() + { + if ($this->dba->delete(static::$table_name, ['id' => $this->id])) { + $this->data = []; + } + } +} diff --git a/src/Model/APContact.php b/src/Model/APContact.php index 346c4ec2f7..e3c3a0a12b 100644 --- a/src/Model/APContact.php +++ b/src/Model/APContact.php @@ -84,7 +84,7 @@ class APContact extends BaseObject public static function getByURL($url, $update = null) { if (empty($url)) { - return false; + return []; } $fetched_contact = false; @@ -110,7 +110,7 @@ class APContact extends BaseObject } if (!is_null($update)) { - return DBA::isResult($apcontact) ? $apcontact : false; + return DBA::isResult($apcontact) ? $apcontact : []; } if (DBA::isResult($apcontact)) { diff --git a/src/Model/Introduction.php b/src/Model/Introduction.php new file mode 100644 index 0000000000..127765c0cb --- /dev/null +++ b/src/Model/Introduction.php @@ -0,0 +1,156 @@ +logger->info('Confirming follower', ['cid' => $this->{'contact-id'}]); + + $contact = Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); + + if (!$contact) { + throw new HTTPException\NotFoundException('Contact record not found.'); + } + + $new_relation = $contact['rel']; + $writable = $contact['writable']; + + if (!empty($contact['protocol'])) { + $protocol = $contact['protocol']; + } else { + $protocol = $contact['network']; + } + + if ($protocol == Protocol::ACTIVITYPUB) { + ActivityPub\Transmitter::sendContactAccept($contact['url'], $contact['hub-verify'], $contact['uid']); + } + + if (in_array($protocol, [Protocol::DIASPORA, Protocol::ACTIVITYPUB])) { + if ($duplex) { + $new_relation = Contact::FRIEND; + } else { + $new_relation = Contact::FOLLOWER; + } + + if ($new_relation != Contact::FOLLOWER) { + $writable = 1; + } + } + + $fields = [ + 'name-date' => DateTimeFormat::utcNow(), + 'uri-date' => DateTimeFormat::utcNow(), + 'blocked' => false, + 'pending' => false, + 'protocol' => $protocol, + 'writable' => $writable, + 'hidden' => $hidden ?? $contact['hidden'], + 'rel' => $new_relation, + ]; + $this->dba->update('contact', $fields, ['id' => $contact['id']]); + + array_merge($contact, $fields); + + if ($new_relation == Contact::FRIEND) { + if ($protocol == Protocol::DIASPORA) { + $ret = Diaspora::sendShare(User::getById($contact['uid']), $contact); + $this->logger->info('share returns', ['return' => $ret]); + } elseif ($protocol == Protocol::ACTIVITYPUB) { + ActivityPub\Transmitter::sendActivity('Follow', $contact['url'], $contact['uid']); + } + } + + $this->delete(); + } + + /** + * Silently ignores the introduction, hides it from notifications and prevents the remote contact from submitting + * additional follow requests. + * + * Chainable + * + * @return Introduction + * @throws \Exception + */ + public function ignore() + { + $this->dba->update('intro', ['ignore' => true], ['id' => $this->id]); + + return $this; + } + + /** + * Discards the introduction and sends a rejection message to AP contacts. + * + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException + * @throws \ImagickException + */ + public function discard() + { + // If it is a friend suggestion, the contact is not a new friend but an existing friend + // that should not be deleted. + if (!$this->fid) { + // When the contact entry had been created just for that intro, we want to get rid of it now + $condition = ['id' => $this->{'contact-id'}, 'uid' => $this->uid, + 'self' => false, 'pending' => true, 'rel' => [0, Contact::FOLLOWER]]; + if ($this->dba->exists('contact', $condition)) { + Contact::remove($this->{'contact-id'}); + } else { + $this->dba->update('contact', ['pending' => false], ['id' => $this->{'contact-id'}]); + } + } + + $contact = Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); + + if (!$contact) { + throw new HTTPException\NotFoundException('Contact record not found.'); + } + + if (!empty($contact['protocol'])) { + $protocol = $contact['protocol']; + } else { + $protocol = $contact['network']; + } + + if ($protocol == Protocol::ACTIVITYPUB) { + ActivityPub\Transmitter::sendContactReject($contact['url'], $contact['hub-verify'], $contact['uid']); + } + + $this->delete(); + } +} diff --git a/src/Module/Api/Mastodon/FollowRequests.php b/src/Module/Api/Mastodon/FollowRequests.php index 515dc451cd..739400eb77 100644 --- a/src/Module/Api/Mastodon/FollowRequests.php +++ b/src/Module/Api/Mastodon/FollowRequests.php @@ -2,11 +2,13 @@ namespace Friendica\Module\Api\Mastodon; -use Friendica\Api\Mastodon\Account; +use Friendica\Api\Mastodon; use Friendica\App\BaseURL; use Friendica\Core\System; use Friendica\Database\DBA; +use Friendica\Model\APContact; use Friendica\Model\Contact; +use Friendica\Model\Introduction; use Friendica\Module\Base\Api; use Friendica\Network\HTTPException; @@ -19,7 +21,40 @@ class FollowRequests extends Api { parent::init($parameters); - self::login(); + if (!self::login()) { + throw new HTTPException\UnauthorizedException(); + } + } + + public static function post(array $parameters = []) + { + parent::post($parameters); + + /** @var Introduction $Intro */ + $Intro = self::getClass(Introduction::class); + $Intro->fetch(['id' => $parameters['id'], 'uid' => self::$current_user_id]); + + $contactId = $Intro->{'contact-id'}; + + $relationship = new Mastodon\Relationship(); + $relationship->id = $contactId; + + switch ($parameters['action']) { + case 'authorize': + $Intro->confirm(); + $relationship = Mastodon\Relationship::createFromContact(Contact::getById($contactId)); + break; + case 'ignore': + $Intro->ignore(); + break; + case 'reject': + $Intro->discard(); + break; + default: + throw new HTTPException\BadRequestException('Unexpected action parameter, expecting "authorize", "ignore" or "reject"'); + } + + System::jsonExit($relationship); } /** @@ -34,26 +69,32 @@ class FollowRequests extends Api $limit = intval($_GET['limit'] ?? 40); if (isset($since_id) && isset($max_id)) { - $condition = ['`uid` = ? AND NOT `self` AND `pending` AND `id` > ? AND `id` < ?', self::$current_user_id, $since_id, $max_id]; + $condition = ['`uid` = ? AND NOT `ignore` AND `id` > ? AND `id` < ?', self::$current_user_id, $since_id, $max_id]; } elseif (isset($since_id)) { - $condition = ['`uid` = ? AND NOT `self` AND `pending` AND `id` > ?', self::$current_user_id, $since_id]; + $condition = ['`uid` = ? AND NOT `ignore` AND `id` > ?', self::$current_user_id, $since_id]; } elseif (isset($max_id)) { - $condition = ['`uid` = ? AND NOT `self` AND `pending` AND `id` < ?', self::$current_user_id, $max_id]; + $condition = ['`uid` = ? AND NOT `ignore` AND `id` < ?', self::$current_user_id, $max_id]; } else { - $condition = ['`uid` = ? AND NOT `self` AND `pending`', self::$current_user_id]; + $condition = ['`uid` = ? AND NOT `ignore`', self::$current_user_id]; } - $count = DBA::count('contact', $condition); + $count = DBA::count('intro', $condition); - $contacts = Contact::selectToArray( + $intros = DBA::selectToArray( + 'intro', [], $condition, ['order' => ['id' => 'DESC'], 'limit' => $limit] ); $return = []; - foreach ($contacts as $contact) { - $account = Account::createFromContact($contact); + foreach ($intros as $intro) { + $contact = Contact::getById($intro['contact-id']); + $apcontact = APContact::getByURL($contact['url'], false); + $account = Mastodon\Account::createFromContact($contact, $apcontact); + + // Not ideal, the same "account" can have multiple ids depending on the context + $account->id = $intro['id']; $return[] = $account; } @@ -68,9 +109,9 @@ class FollowRequests extends Api $links = []; if ($count > $limit) { - $links[] = '<' . $BaseURL->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['max_id' => $contacts[count($contacts) - 1]['id']]) . '>; rel="next"'; + $links[] = '<' . $BaseURL->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['max_id' => $intros[count($intros) - 1]['id']]) . '>; rel="next"'; } - $links[] = '<' . $BaseURL->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['since_id' => $contacts[0]['id']]) . '>; rel="prev"'; + $links[] = '<' . $BaseURL->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['since_id' => $intros[0]['id']]) . '>; rel="prev"'; header('Link: ' . implode(', ', $links)); diff --git a/src/Module/Base/Api.php b/src/Module/Base/Api.php index f3453e0323..08cf96158d 100644 --- a/src/Module/Base/Api.php +++ b/src/Module/Base/Api.php @@ -54,6 +54,7 @@ class Api extends BaseModule * * @brief Login API user * + * @return bool Was a user authenticated? * @throws HTTPException\ForbiddenException * @throws HTTPException\UnauthorizedException * @throws HTTPException\InternalServerErrorException @@ -69,6 +70,8 @@ class Api extends BaseModule api_login(self::getApp()); self::$current_user_id = api_user(); + + return (bool)self::$current_user_id; } /** diff --git a/src/Module/FollowConfirm.php b/src/Module/FollowConfirm.php index d1a0a5dda5..5e9ab0481c 100644 --- a/src/Module/FollowConfirm.php +++ b/src/Module/FollowConfirm.php @@ -1,17 +1,9 @@ fetch(['id' => $intro_id, 'uid' => local_user()]); - Logger::info('Confirming follower', ['cid' => $cid]); + $cid = $Intro->{'contact-id'}; - $contact = DBA::selectFirst('contact', [], ['id' => $cid, 'uid' => $uid]); - if (!DBA::isResult($contact)) { - Logger::warning('Contact not found in DB.', ['cid' => $cid]); - notice(L10n::t('Contact not found.') . EOL); - return; - } - - $relation = $contact['rel']; - $new_relation = $contact['rel']; - $writable = $contact['writable']; - - if (!empty($contact['protocol'])) { - $protocol = $contact['protocol']; - } else { - $protocol = $contact['network']; - } - - if ($protocol == Protocol::ACTIVITYPUB) { - ActivityPub\Transmitter::sendContactAccept($contact['url'], $contact['hub-verify'], $uid); - } - - if (in_array($protocol, [Protocol::DIASPORA, Protocol::ACTIVITYPUB])) { - if ($duplex) { - $new_relation = Contact::FRIEND; - } else { - $new_relation = Contact::FOLLOWER; - } - - if ($new_relation != Contact::FOLLOWER) { - $writable = 1; - } - } - - $fields = ['name-date' => DateTimeFormat::utcNow(), - 'uri-date' => DateTimeFormat::utcNow(), - 'blocked' => false, 'pending' => false, 'protocol' => $protocol, - 'writable' => $writable, 'hidden' => $hidden, 'rel' => $new_relation]; - DBA::update('contact', $fields, ['id' => $cid]); - - if ($new_relation == Contact::FRIEND) { - if ($protocol == Protocol::DIASPORA) { - $user = User::getById($uid); - $contact = Contact::getById($cid); - $ret = Diaspora::sendShare($user, $contact); - Logger::info('share returns', ['return' => $ret]); - } elseif ($protocol == Protocol::ACTIVITYPUB) { - ActivityPub\Transmitter::sendActivity('Follow', $contact['url'], $uid); - } - } - - DBA::delete('intro', ['id' => $intro_id]); + $Intro->confirm($duplex, $hidden); $a->internalRedirect('contact/' . intval($cid)); } diff --git a/static/routes.config.php b/static/routes.config.php index 824354690d..d23b092169 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -30,6 +30,7 @@ return [ '/api' => [ '/v1' => [ '/follow_requests' => [Module\Api\Mastodon\FollowRequests::class, [R::GET ]], + '/follow_requests/{id:\d+}/{action}' => [Module\Api\Mastodon\FollowRequests::class, [ R::POST]], '/instance' => [Module\Api\Mastodon\Instance::class, [R::GET]], '/instance/peers' => [Module\Api\Mastodon\Instance\Peers::class, [R::GET]], ],