diff --git a/database.sql b/database.sql index 7a18fad5cb..666a48f667 100644 --- a/database.sql +++ b/database.sql @@ -1,6 +1,6 @@ -- ------------------------------------------ -- Friendica 2021.06-dev (Siberian Iris) --- DB_UPDATE_VERSION 1414 +-- DB_UPDATE_VERSION 1415 -- ------------------------------------------ @@ -364,6 +364,21 @@ CREATE TABLE IF NOT EXISTS `apcontact` ( FOREIGN KEY (`gsid`) REFERENCES `gserver` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='ActivityPub compatible contacts - used in the ActivityPub implementation'; +-- +-- TABLE application +-- +CREATE TABLE IF NOT EXISTS `application` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'generated index', + `client_id` varchar(64) NOT NULL COMMENT '', + `client_secret` varchar(64) NOT NULL COMMENT '', + `name` varchar(255) NOT NULL COMMENT '', + `redirect_uri` varchar(255) NOT NULL COMMENT '', + `website` varchar(255) COMMENT '', + `scopes` varchar(255) COMMENT '', + PRIMARY KEY(`id`), + UNIQUE INDEX `client_id` (`client_id`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth application'; + -- -- TABLE attach -- diff --git a/doc/API-Mastodon.md b/doc/API-Mastodon.md index 811bb5bc53..7f5b55797a 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -15,13 +15,15 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en ## Implemented endpoints -- [`GET /api/v1//accounts/:id`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) -- [`GET /api/v1//accounts/:id/statuses`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) -- [`GET /api/v1//accounts/:id/followers`](https://docs.joinmastodon.org/methods/accounts/) -- [`GET /api/v1//accounts/:id/following`](https://docs.joinmastodon.org/methods/accounts/) +- [`GET /api/v1/accounts/:id`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) +- [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) +- [`GET /api/v1/accounts/:id/followers`](https://docs.joinmastodon.org/methods/accounts/) +- [`GET /api/v1/accounts/:id/following`](https://docs.joinmastodon.org/methods/accounts/) +- [`GET /api/v1/accounts/:id/lists`](https://docs.joinmastodon.org/methods/accounts/) +- [`GET /api/v1/accounts/search`](https://docs.joinmastodon.org/methods/accounts) +- [`GET /api/v1/accounts/verify_credentials`](https://docs.joinmastodon.org/methods/accounts) - [`GET /api/v1/blocks`](https://docs.joinmastodon.org/methods/accounts/blocks/) - [`GET /api/v1/bookmarks`](https://docs.joinmastodon.org/methods/accounts/bookmarks/) -- [`GET /api/v1//accounts/verify_credentials`](https://docs.joinmastodon.org/methods/accounts) - [`GET /api/v1/custom_emojis`](https://docs.joinmastodon.org/methods/instance/custom_emojis/) - Doesn't return unicode emojis since they aren't using an image URL @@ -42,7 +44,14 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en - [`GET /api/v1/instance`](https://docs.joinmastodon.org/methods/instance#fetch-instance) - [`GET /api/v1/instance/peers`](https://docs.joinmastodon.org/methods/instance#list-of-connected-domains) +- [`GET /api/v1/lists`](https://docs.joinmastodon.org/methods/timelines/lists/) +- [`GET /api/v1/lists/:id`](https://docs.joinmastodon.org/methods/timelines/lists/) +- [`GET /api/v1/lists/:id/accounts`](https://docs.joinmastodon.org/methods/timelines/lists/) +- [`GET /api/v1/media/:id`](https://docs.joinmastodon.org/methods/statuses/media/) - [`GET /api/v1/mutes`](https://docs.joinmastodon.org/methods/accounts/mutes/) +- [`GET /api/v1/notifications`](https://docs.joinmastodon.org/methods/notifications/) +- [`GET /api/v1/notifications/:id`](https://docs.joinmastodon.org/methods/notifications/) +- [`GET /api/v1/preferences`](https://docs.joinmastodon.org/methods/accounts/preferences/) - [`GET /api/v1/statuses/:id`](https://docs.joinmastodon.org/methods/statuses/) - [`GET /api/v1/statuses/:id/context`](https://docs.joinmastodon.org/methods/statuses/) - [`GET /api/v1/statuses/:id/reblogged_by`](https://docs.joinmastodon.org/methods/statuses/) diff --git a/include/enotify.php b/include/enotify.php index 3aa38c1c44..1edad36ac0 100644 --- a/include/enotify.php +++ b/include/enotify.php @@ -41,7 +41,7 @@ use Friendica\Protocol\Activity; * type, event, otype, activity, verb, uid, cid, origin_cid, item, link, * source_name, source_mail, source_nick, source_link, source_photo, * show_in_notification_page - * + * * @return bool * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ @@ -273,7 +273,7 @@ function notification($params) $epreamble = $l10n->t('%1$s [url=%2$s]shared a post[/url] from %3$s.', '[url='.$params['source_link'].']'.$params['source_name'].'[/url]', $params['link'], '[url='.$params['origin_link'].']'.$params['origin_name'].'[/url]' - ); + ); } $sitelink = $l10n->t('Please visit %s to view and/or reply to the conversation.'); diff --git a/src/DI.php b/src/DI.php index c2f53e4df9..37091a5ab8 100644 --- a/src/DI.php +++ b/src/DI.php @@ -239,6 +239,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Account::class); } + /** + * @return Factory\Api\Mastodon\Application + */ + public static function mstdnApplication() + { + return self::$dice->create(Factory\Api\Mastodon\Application::class); + } + /** * @return Factory\Api\Mastodon\Attachment */ @@ -295,6 +303,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Status::class); } + /** + * @return Factory\Api\Mastodon\ListEntity + */ + public static function mstdnList() + { + return self::$dice->create(Factory\Api\Mastodon\ListEntity::class); + } + /** * @return Factory\Api\Mastodon\Mention */ @@ -303,6 +319,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Mention::class); } + /** + * @return Factory\Api\Mastodon\Notification + */ + public static function mstdnNotification() + { + return self::$dice->create(Factory\Api\Mastodon\Notification::class); + } + /** * @return Factory\Api\Mastodon\Tag */ diff --git a/src/Factory/Api/Mastodon/Application.php b/src/Factory/Api/Mastodon/Application.php new file mode 100644 index 0000000000..3a184d8a5e --- /dev/null +++ b/src/Factory/Api/Mastodon/Application.php @@ -0,0 +1,49 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\BaseFactory; +use Friendica\Database\DBA; + +class Application extends BaseFactory +{ + /** + * @param int $id Application ID + */ + public function createFromApplicationId(int $id) + { + $application = DBA::selectFirst('application', ['client_id', 'client_secret', 'id', 'name', 'redirect_uri', 'website'], ['id' => $id]); + if (!DBA::isResult($application)) { + return []; + } + + $object = new \Friendica\Object\Api\Mastodon\Application( + $application['name'], + $application['client_id'], + $application['client_secret'], + $application['id'], + $application['redirect_uri'], + $application['website']); + + return $object->toArray(); + } +} diff --git a/src/Factory/Api/Mastodon/Attachment.php b/src/Factory/Api/Mastodon/Attachment.php index 893b4c9f95..7ac45f354e 100644 --- a/src/Factory/Api/Mastodon/Attachment.php +++ b/src/Factory/Api/Mastodon/Attachment.php @@ -23,9 +23,12 @@ namespace Friendica\Factory\Api\Mastodon; use Friendica\App\BaseURL; use Friendica\BaseFactory; +use Friendica\DI; +use Friendica\Model\Photo; use Friendica\Network\HTTPException; use Friendica\Model\Post; use Friendica\Repository\ProfileField; +use Friendica\Util\Images; use Friendica\Util\Proxy; use Psr\Log\LoggerInterface; @@ -93,4 +96,36 @@ class Attachment extends BaseFactory return $attachments; } + + /** + * @param int $id id of the photo + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromPhoto(int $id) + { + $photo = Photo::selectFirst(['resource-id', 'uid', 'id', 'title', 'type'], ['id' => $id]); + if (empty($photo)) { + return null; + } + + $attachment = ['id' => $photo['id'], 'description' => $photo['title']]; + + $phototypes = Images::supportedTypes(); + $ext = $phototypes[$photo['type']]; + + $url = DI::baseUrl() . '/photo/' . $photo['resource-id'] . '-0.' . $ext; + + $preview = Photo::selectFirst(['scale'], ["`resource-id` = ? AND `uid` = ? AND `scale` > ?", $photo['resource-id'], $photo['uid'], 0], ['order' => ['scale']]); + if (empty($scale)) { + $preview_url = DI::baseUrl() . '/photo/' . $photo['resource-id'] . '-' . $preview['scale'] . '.' . $ext; + } else { + $preview_url = ''; + } + + + $object = new \Friendica\Object\Api\Mastodon\Attachment($attachment, 'image', $url, $preview_url, ''); + return $object->toArray(); + } } diff --git a/src/Factory/Api/Mastodon/ListEntity.php b/src/Factory/Api/Mastodon/ListEntity.php new file mode 100644 index 0000000000..a149b25af2 --- /dev/null +++ b/src/Factory/Api/Mastodon/ListEntity.php @@ -0,0 +1,34 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\BaseFactory; +use Friendica\Database\DBA; + +class ListEntity extends BaseFactory +{ + public function createFromGroupId(int $id) + { + $group = DBA::selectFirst('group', ['name'], ['id' => $id, 'deleted' => false]); + return new \Friendica\Object\Api\Mastodon\ListEntity($id, $group['name'] ?? '', 'list'); + } +} diff --git a/src/Factory/Api/Mastodon/Notification.php b/src/Factory/Api/Mastodon/Notification.php new file mode 100644 index 0000000000..db5e7d4898 --- /dev/null +++ b/src/Factory/Api/Mastodon/Notification.php @@ -0,0 +1,89 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\BaseFactory; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Model\Notification as ModelNotification; + +class Notification extends BaseFactory +{ + public function createFromNotifyId(int $id) + { + $notification = DBA::selectFirst('notify', [], ['id' => $id]); + if (!DBA::isResult($notification)) { + return null; + } + + $cid = Contact::getIdForURL($notification['url'], 0, false); + if (empty($cid)) { + return null; + } + + /* + follow = Someone followed you + follow_request = Someone requested to follow you + mention = Someone mentioned you in their status + reblog = Someone boosted one of your statuses + favourite = Someone favourited one of your statuses + poll = A poll you have voted in or created has ended + status = Someone you enabled notifications for has posted a status + */ + + switch ($notification['type']) { + case ModelNotification\Type::INTRO: + $type = 'follow_request'; + break; + + case ModelNotification\Type::WALL: + case ModelNotification\Type::COMMENT: + case ModelNotification\Type::MAIL: + case ModelNotification\Type::TAG_SELF: + case ModelNotification\Type::POKE: + $type = 'mention'; + break; + + case ModelNotification\Type::SHARE: + $type = 'status'; + break; + + default: + return null; + } + + $account = DI::mstdnAccount()->createFromContactId($cid); + + if (!empty($notification['uri-id'])) { + try { + $status = DI::mstdnStatus()->createFromUriId($notification['uri-id'], $notification['uid']); + } catch (\Throwable $th) { + $status = null; + } + } else { + $status = null; + } + + return new \Friendica\Object\Api\Mastodon\Notification($id, $type, $notification['date'], $account, $status); + } +} diff --git a/src/Model/Contact.php b/src/Model/Contact.php index 411cf333ef..f75cffadac 100644 --- a/src/Model/Contact.php +++ b/src/Model/Contact.php @@ -264,7 +264,7 @@ class Contact $condition = ['`alias` IN (?, ?, ?) AND `uid` = ? AND NOT `deleted`', $url, Strings::normaliseLink($url), $ssl_url, $uid]; $contact = DBA::selectFirst('contact', $fields, $condition, $options); } - + if (!DBA::isResult($contact)) { return []; } @@ -307,7 +307,7 @@ class Contact } $contact = self::getByURL($url, $update, $fields); - if (!empty($contact['id'])) { + if (!empty($contact['id'])) { $contact['cid'] = 0; $contact['zid'] = $contact['id']; } @@ -1274,7 +1274,7 @@ class Contact * * @param string $contact_url Contact URL * @param bool $thread_mode - * @param int $update Update mode + * @param int $update Update mode * @param int $parent Item parent ID for the update mode * @return string posts in HTML * @throws \Exception @@ -1289,7 +1289,7 @@ class Contact * * @param int $cid Contact ID * @param bool $thread_mode - * @param int $update Update mode + * @param int $update Update mode * @param int $parent Item parent ID for the update mode * @return string posts in HTML * @throws \Exception @@ -1347,7 +1347,7 @@ class Contact $o = ''; } - if ($thread_mode) { + if ($thread_mode) { $items = Post::toArray(Post::selectForUser(local_user(), ['uri-id', 'gravity', 'parent-uri-id', 'thr-parent-id', 'author-id'], $condition, $params)); $o .= conversation($a, $items, 'contacts', $update, false, 'commented', local_user()); @@ -1607,12 +1607,12 @@ class Contact $avatar['size'] = 48; $default = self::DEFAULT_AVATAR_MICRO; break; - + case Proxy::SIZE_THUMB: $avatar['size'] = 80; $default = self::DEFAULT_AVATAR_THUMB; break; - + case Proxy::SIZE_SMALL: default: $avatar['size'] = 300; @@ -1720,7 +1720,7 @@ class Contact $contact['thumb'] ?? '', $contact['micro'] ?? '', ]; - + foreach ($data as $image_uri) { $image_rid = Photo::ridFromURI($image_uri); if ($image_rid && !Photo::exists(['resource-id' => $image_rid, 'uid' => $uid])) { @@ -2027,7 +2027,7 @@ class Contact if (Contact\Relation::isDiscoverable($ret['url'])) { Worker::add(PRIORITY_LOW, 'ContactDiscovery', $ret['url']); } - + // Update the public contact if ($uid != 0) { $contact = self::getByURL($ret['url'], false, ['id']); @@ -2763,11 +2763,12 @@ class Contact * * @param string $search Name or nick * @param string $mode Search mode (e.g. "community") + * @param int $uid User ID * * @return array with search results * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function searchByName($search, $mode = '') + public static function searchByName(string $search, string $mode = '', int $uid = 0) { if (empty($search)) { return []; @@ -2800,7 +2801,7 @@ class Contact NOT `failed` AND `uid` = ? AND (`addr` LIKE ? OR `name` LIKE ? OR `nick` LIKE ?) $extra_sql ORDER BY `nurl` DESC LIMIT 1000", - Protocol::DFRN, Protocol::ACTIVITYPUB, $ostatus, $diaspora, 0, $search, $search, $search + Protocol::DFRN, Protocol::ACTIVITYPUB, $ostatus, $diaspora, $uid, $search, $search, $search ); $contacts = DBA::toArray($results); diff --git a/src/Module/Api/Mastodon/Accounts/Followers.php b/src/Module/Api/Mastodon/Accounts/Followers.php index 739341a2fa..ab2ca68391 100644 --- a/src/Module/Api/Mastodon/Accounts/Followers.php +++ b/src/Module/Api/Mastodon/Accounts/Followers.php @@ -81,6 +81,10 @@ class Followers extends BaseApi } DBA::close($followers); + if (!empty($min_id)) { + array_reverse($accounts); + } + System::jsonExit($accounts); } } diff --git a/src/Module/Api/Mastodon/Accounts/Following.php b/src/Module/Api/Mastodon/Accounts/Following.php index 10d73e9071..414213dc44 100644 --- a/src/Module/Api/Mastodon/Accounts/Following.php +++ b/src/Module/Api/Mastodon/Accounts/Following.php @@ -81,6 +81,10 @@ class Following extends BaseApi } DBA::close($followers); + if (!empty($min_id)) { + array_reverse($accounts); + } + System::jsonExit($accounts); } } diff --git a/src/Module/Api/Mastodon/Accounts/Lists.php b/src/Module/Api/Mastodon/Accounts/Lists.php new file mode 100644 index 0000000000..b15124a45c --- /dev/null +++ b/src/Module/Api/Mastodon/Accounts/Lists.php @@ -0,0 +1,66 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon\Accounts; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/ + */ +class Lists extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + if (empty($parameters['id'])) { + DI::mstdnError()->RecordNotFound(); + } + + $id = $parameters['id']; + if (!DBA::exists('contact', ['id' => $id, 'uid' => 0])) { + DI::mstdnError()->RecordNotFound(); + } + + $lists = []; + + $cdata = Contact::getPublicAndUserContacID($id, $uid); + if (!empty($cdata['user'])) { + $groups = DBA::select('group_member', ['gid'], ['contact-id' => $cdata['user']]); + while ($group = DBA::fetch($groups)) { + $lists[] = DI::mstdnList()->createFromGroupId($group['gid']); + } + DBA::close($groups); + } + + System::jsonExit($lists); + } +} diff --git a/src/Module/Api/Mastodon/Accounts/Search.php b/src/Module/Api/Mastodon/Accounts/Search.php new file mode 100644 index 0000000000..77906661d2 --- /dev/null +++ b/src/Module/Api/Mastodon/Accounts/Search.php @@ -0,0 +1,101 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon\Accounts; + +use Friendica\Core\Search as CoreSearch; +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Module\BaseApi; +use Friendica\Object\Search\ContactResult; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/ + */ +class Search extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + // What to search for + $q = (int)!isset($_REQUEST['q']) ? 0 : $_REQUEST['q']; + // Maximum number of results. Defaults to 40. + $limit = (int)!isset($_REQUEST['limit']) ? 40 : $_REQUEST['limit']; + // Attempt WebFinger lookup. Defaults to false. Use this when q is an exact address. + $resolve = (int)!isset($_REQUEST['resolve']) ? 0 : $_REQUEST['resolve']; + // Only who the user is following. Defaults to false. + $following = (int)!isset($_REQUEST['following']) ? 0 : $_REQUEST['following']; + + $accounts = []; + + if (!$following) { + if ((strrpos($q, '@') > 0) && $resolve) { + $results = CoreSearch::getContactsFromProbe($q); + } + + if (empty($results)) { + if (DI::config()->get('system', 'poco_local_search')) { + $results = CoreSearch::getContactsFromLocalDirectory($q, CoreSearch::TYPE_ALL, 0, $limit); + } elseif (!empty(DI::config()->get('system', 'directory'))) { + $results = CoreSearch::getContactsFromGlobalDirectory($q, CoreSearch::TYPE_ALL, 1); + } + } + + if (!empty($results)) { + $counter = 0; + foreach ($results->getResults() as $result) { + if (++$counter > $limit) { + continue; + } + if ($result instanceof ContactResult) { + $id = Contact::getIdForURL($result->getUrl(), 0, false); + + $accounts[] = DI::mstdnAccount()->createFromContactId($id, $uid); + } + } + } + } else { + $contacts = Contact::searchByName($q, '', $uid); + + $counter = 0; + foreach ($contacts as $contact) { + if (!in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) { + continue; + } + if (++$counter > $limit) { + continue; + } + $accounts[] = DI::mstdnAccount()->createFromContactId($contact['id'], $uid); + } + DBA::close($contacts); + } + + System::jsonExit($accounts); + } +} diff --git a/src/Module/Api/Mastodon/Apps.php b/src/Module/Api/Mastodon/Apps.php new file mode 100644 index 0000000000..a86d5cc993 --- /dev/null +++ b/src/Module/Api/Mastodon/Apps.php @@ -0,0 +1,68 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Module\BaseApi; + +/** + * Apps class to register new OAuth clients + */ +class Apps extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function post(array $parameters = []) + { + $name = !isset($_REQUEST['client_name']) ? '' : $_REQUEST['client_name']; + $redirect = !isset($_REQUEST['redirect_uris']) ? '' : $_REQUEST['redirect_uris']; + $scopes = !isset($_REQUEST['scopes']) ? '' : $_REQUEST['scopes']; + $website = !isset($_REQUEST['website']) ? '' : $_REQUEST['website']; + + if (empty($name) || empty($redirect)) { + DI::mstdnError()->RecordNotFound(); + } + + $client_id = base64_encode(openssl_random_pseudo_bytes(32)); + $client_secret = bin2hex(random_bytes(32)); + + $fields = ['client_id' => $client_id, 'client_secret' => $client_secret, 'name' => $name, 'redirect_uri' => $redirect]; + + if (!empty($scopes)) { + $fields['scopes'] = $scopes; + } + + if (!empty($website)) { + $fields['website'] = $website; + } + + if (!DBA::insert('application', $fields)) { + DI::mstdnError()->RecordNotFound(); + } + + System::jsonExit(DI::mstdnApplication()->createFromApplicationId(DBA::lastInsertId())); + } +} diff --git a/src/Module/Api/Mastodon/Blocks.php b/src/Module/Api/Mastodon/Blocks.php index 7f968f64b6..af8e77f889 100644 --- a/src/Module/Api/Mastodon/Blocks.php +++ b/src/Module/Api/Mastodon/Blocks.php @@ -80,6 +80,10 @@ class Blocks extends BaseApi } DBA::close($followers); + if (!empty($min_id)) { + array_reverse($accounts); + } + System::jsonExit($accounts); } } diff --git a/src/Module/Api/Mastodon/Lists.php b/src/Module/Api/Mastodon/Lists.php new file mode 100644 index 0000000000..d9f9c6e5a8 --- /dev/null +++ b/src/Module/Api/Mastodon/Lists.php @@ -0,0 +1,76 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/timelines/lists/ + */ +class Lists extends BaseApi +{ + public static function delete(array $parameters = []) + { + self::unsupported('delete'); + } + + public static function post(array $parameters = []) + { + self::unsupported('post'); + } + + public static function put(array $parameters = []) + { + self::unsupported('put'); + } + + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + if (empty($parameters['id'])) { + $lists = []; + + $groups = DBA::select('group', ['id'], ['uid' => $uid, 'deleted' => false]); + while ($group = DBA::fetch($groups)) { + $lists[] = DI::mstdnList()->createFromGroupId($group['id']); + } + DBA::close($groups); + } else { + $id = $parameters['id']; + if (!DBA::exists('group',['uid' => $uid, 'deleted' => false])) { + DI::mstdnError()->RecordNotFound(); + } + $lists = DI::mstdnList()->createFromGroupId($id); + } + + System::jsonExit($lists); + } +} diff --git a/src/Module/Api/Mastodon/Lists/Accounts.php b/src/Module/Api/Mastodon/Lists/Accounts.php new file mode 100644 index 0000000000..0b817cfc82 --- /dev/null +++ b/src/Module/Api/Mastodon/Lists/Accounts.php @@ -0,0 +1,102 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon\Lists; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/timelines/lists/ + * + * Currently the output will be unordered since we use public contact ids in the api and not user contact ids. + */ +class Accounts extends BaseApi +{ + public static function delete(array $parameters = []) + { + self::unsupported('delete'); + } + + public static function post(array $parameters = []) + { + self::unsupported('post'); + } + + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + if (empty($parameters['id'])) { + DI::mstdnError()->RecordNotFound(); + } + + $id = $parameters['id']; + if (!DBA::exists('group', ['id' => $id, 'uid' => $uid])) { + DI::mstdnError()->RecordNotFound(); + } + + // Return results older than this id + $max_id = (int)!isset($_REQUEST['max_id']) ? 0 : $_REQUEST['max_id']; + // Return results newer than this id + $since_id = (int)!isset($_REQUEST['since_id']) ? 0 : $_REQUEST['since_id']; + // Maximum number of results to return. Defaults to 20. + $limit = (int)!isset($_REQUEST['limit']) ? 40 : $_REQUEST['limit']; + + + $params = ['order' => ['contact-id' => true], 'limit' => $limit]; + + $condition = ['gid' => $id]; + + if (!empty($max_id)) { + $condition = DBA::mergeConditions($condition, ["`contact-id` < ?", $max_id]); + } + + if (!empty($since_id)) { + $condition = DBA::mergeConditions($condition, ["`contact-id` > ?", $since_id]); + } + + if (!empty($min_id)) { + $condition = DBA::mergeConditions($condition, ["`contact-id` > ?", $min_id]); + + $params['order'] = ['contact-id']; + } + + $members = DBA::select('group_member', ['contact-id'], $condition, $params); + while ($member = DBA::fetch($members)) { + $accounts[] = DI::mstdnAccount()->createFromContactId($member['contact-id'], $uid); + } + DBA::close($members); + + if (!empty($min_id)) { + array_reverse($accounts); + } + + System::jsonExit($accounts); + } +} diff --git a/src/Module/Api/Mastodon/Media.php b/src/Module/Api/Mastodon/Media.php new file mode 100644 index 0000000000..bc55610b26 --- /dev/null +++ b/src/Module/Api/Mastodon/Media.php @@ -0,0 +1,59 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Photo; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/statuses/media/ + */ +class Media extends BaseApi +{ + public static function put(array $parameters = []) + { + self::unsupported('put'); + } + + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + if (empty($parameters['id'])) { + DI::mstdnError()->RecordNotFound(); + } + + $id = $parameters['id']; + if (!Photo::exists(['id' => $id, 'uid' => $uid])) { + DI::mstdnError()->RecordNotFound(); + } + + System::jsonExit(DI::mstdnAttachment()->createFromPhoto($id)); + } +} diff --git a/src/Module/Api/Mastodon/Mutes.php b/src/Module/Api/Mastodon/Mutes.php index b5ec30de4d..cb981b33eb 100644 --- a/src/Module/Api/Mastodon/Mutes.php +++ b/src/Module/Api/Mastodon/Mutes.php @@ -80,6 +80,10 @@ class Mutes extends BaseApi } DBA::close($followers); + if (!empty($min_id)) { + array_reverse($accounts); + } + System::jsonExit($accounts); } } diff --git a/src/Module/Api/Mastodon/Notifications.php b/src/Module/Api/Mastodon/Notifications.php new file mode 100644 index 0000000000..a10ebf5d50 --- /dev/null +++ b/src/Module/Api/Mastodon/Notifications.php @@ -0,0 +1,122 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Model\Notification; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/mutes/ + */ +class Notifications extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + if (!empty($parameters['id'])) { + $id = $parameters['id']; + if (!DBA::exists('notify', ['id' => $id, 'uid' => $uid])) { + DI::mstdnError()->RecordNotFound(); + } + System::jsonExit(DI::mstdnNotification()->createFromNotifyId($id)); + } + + // Return results older than this ID + $max_id = (int)!isset($_REQUEST['max_id']) ? 0 : $_REQUEST['max_id']; + + // Return results newer than this ID + $since_id = (int)!isset($_REQUEST['since_id']) ? 0 : $_REQUEST['since_id']; + + // Return results immediately newer than this ID + $min_id = (int)!isset($_REQUEST['min_id']) ? 0 : $_REQUEST['min_id']; + + // Maximum number of results to return (default 20) + $limit = (int)!isset($_REQUEST['limit']) ? 20 : $_REQUEST['limit']; + + // Array of types to exclude (follow, favourite, reblog, mention, poll, follow_request) + $exclude_types = !isset($_REQUEST['exclude_types']) ? [] : $_REQUEST['exclude_types']; + + // Return only notifications received from this account + $account_id = (int)!isset($_REQUEST['account_id']) ? 0 : $_REQUEST['account_id']; + + $params = ['order' => ['id' => true], 'limit' => $limit]; + + $condition = ['uid' => $uid, 'seen' => false, 'type' => []]; + + if (!empty($account_id)) { + $contact = Contact::getById($account_id, ['url']); + if (!empty($contact['url'])) { + $condition['url'] = $contact['url']; + } + } + + if (!in_array('follow_request', $exclude_types)) { + $condition['type'] = array_merge($condition['type'], [Notification\Type::INTRO]); + } + + if (!in_array('mention', $exclude_types)) { + $condition['type'] = array_merge($condition['type'], + [Notification\Type::WALL, Notification\Type::COMMENT, Notification\Type::MAIL, Notification\Type::TAG_SELF, Notification\Type::POKE]); + } + + if (!in_array('status', $exclude_types)) { + $condition['type'] = array_merge($condition['type'], [Notification\Type::SHARE]); + } + + if (!empty($max_id)) { + $condition = DBA::mergeConditions($condition, ["`id` < ?", $max_id]); + } + + if (!empty($since_id)) { + $condition = DBA::mergeConditions($condition, ["`id` > ?", $since_id]); + } + + if (!empty($min_id)) { + $condition = DBA::mergeConditions($condition, ["`id` > ?", $min_id]); + + $params['order'] = ['id']; + } + + $notifications = []; + + $notify = DBA::select('notify', ['id'], $condition, $params); + while ($notification = DBA::fetch($notify)) { + $notifications[] = DI::mstdnNotification()->createFromNotifyId($notification['id']); + } + + if (!empty($min_id)) { + array_reverse($notifications); + } + + System::jsonExit($notifications); + } +} diff --git a/src/Module/Api/Mastodon/Preferences.php b/src/Module/Api/Mastodon/Preferences.php new file mode 100644 index 0000000000..035357f07d --- /dev/null +++ b/src/Module/Api/Mastodon/Preferences.php @@ -0,0 +1,61 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/preferences/ + */ +class Preferences extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + self::login(); + $uid = self::getCurrentUserID(); + + $user = User::getById($uid, ['language', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']); + if (!empty($user['allow_cid']) || !empty($user['allow_gid']) || !empty($user['deny_cid']) || !empty($user['deny_gid'])) { + $visibility = 'private'; + } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) { + $visibility = 'unlisted'; + } else { + $visibility = 'public'; + } + + $sensitive = false; + $language = $user['language']; + $media = DI::pConfig()->get($uid, 'nsfw', 'disable') ? 'show_all' : 'default'; + $spoilers = DI::pConfig()->get($uid, 'system', 'disable_cw'); + + $preferences = new \Friendica\Object\Api\Mastodon\Preferences($visibility, $sensitive, $language, $media, $spoilers); + + System::jsonExit($preferences); + } +} diff --git a/src/Object/Api/Mastodon/Application.php b/src/Object/Api/Mastodon/Application.php index c8bb03a30d..1f31ca8ec7 100644 --- a/src/Object/Api/Mastodon/Application.php +++ b/src/Object/Api/Mastodon/Application.php @@ -30,8 +30,18 @@ use Friendica\BaseDataTransferObject; */ class Application extends BaseDataTransferObject { + /** @var string */ + protected $client_id; + /** @var string */ + protected $client_secret; + /** @var int */ + protected $id; /** @var string */ protected $name; + /** @var string */ + protected $redirect_uri; + /** @var string */ + protected $website; /** * Creates an application entry @@ -39,8 +49,36 @@ class Application extends BaseDataTransferObject * @param array $item * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public function __construct(string $name) + public function __construct(string $name, string $client_id = null, string $client_secret = null, int $id = null, string $redirect_uri = null, string $website = null) { - $this->name = $name; + $this->client_id = $client_id; + $this->client_secret = $client_secret; + $this->id = $id; + $this->name = $name; + $this->redirect_uri = $redirect_uri; + $this->website = $website; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray(): array + { + $application = parent::toArray(); + + if (empty($application['id'])) { + unset($application['client_id']); + unset($application['client_secret']); + unset($application['id']); + unset($application['redirect_uri']); + } + + if (empty($application['website'])) { + unset($application['website']); + } + + return $application; } } diff --git a/src/Object/Api/Mastodon/ListEntity.php b/src/Object/Api/Mastodon/ListEntity.php new file mode 100644 index 0000000000..b3407d28ed --- /dev/null +++ b/src/Object/Api/Mastodon/ListEntity.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseDataTransferObject; + +/** + * Class ListEntity + * + * @see https://docs.joinmastodon.org/entities/list/ + */ +class ListEntity extends BaseDataTransferObject +{ + /** @var string */ + protected $id; + /** @var string */ + protected $title; + + /** + * Creates an list record + * + * @param int $id + * @param string $title + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(int $id, string $title, string $policy) + { + $this->id = (string)$id; + $this->title = $title; + $this->replies_policy = $policy; + } +} diff --git a/src/Object/Api/Mastodon/Notification.php b/src/Object/Api/Mastodon/Notification.php new file mode 100644 index 0000000000..ee4930e3cd --- /dev/null +++ b/src/Object/Api/Mastodon/Notification.php @@ -0,0 +1,78 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseDataTransferObject; +use Friendica\Util\DateTimeFormat; + +/** + * Class Notification + * + * @see https://docs.joinmastodon.org/entities/notification/ + */ +class Notification extends BaseDataTransferObject +{ + /** @var string */ + protected $id; + /** @var string (Enumerable oneOf) */ + protected $type; + /** @var string (Datetime) */ + protected $created_at; + /** @var Account */ + protected $account; + /** @var Status|null */ + protected $status = null; + + /** + * Creates a notification record + * + * @param array $item + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(int $id, string $type, string $created_at, Account $account = null, Status $status = null) + { + $this->id = (string)$id; + $this->type = $type; + $this->created_at = DateTimeFormat::utc($created_at, DateTimeFormat::ATOM); + $this->account = $account->toArray(); + + if (!empty($status)) { + $this->status = $status->toArray(); + } + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray(): array + { + $notification = parent::toArray(); + + if (!$notification['status']) { + unset($notification['status']); + } + + return $notification; + } +} diff --git a/src/Object/Api/Mastodon/Preferences.php b/src/Object/Api/Mastodon/Preferences.php new file mode 100644 index 0000000000..d633c1128c --- /dev/null +++ b/src/Object/Api/Mastodon/Preferences.php @@ -0,0 +1,62 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseDataTransferObject; + +/** + * Class Preferences + * + * @see https://docs.joinmastodon.org/entities/preferences/ + */ +class Preferences extends BaseDataTransferObject +{ +// /** @var string (Enumerable, oneOf) */ +// protected $posting_default_visibility; +// /** @var bool */ +// protected $posting_default_sensitive; +// /** @var string (ISO 639-1 language two-letter code), or null*/ +// protected $posting_default_language; +// /** @var string (Enumerable, oneOf) */ +// protected $reading_expand_media; +// /** @var bool */ +// protected $reading_expand_spoilers; + + /** + * Creates a preferences record. + * + * @param BaseURL $baseUrl + * @param array $publicContact Full contact table record with uid = 0 + * @param array $apcontact Optional full apcontact table record + * @param array $userContact Optional full contact table record with uid != 0 + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(string $visibility, bool $sensitive, string $language, string $media, bool $spoilers) + { + $this->{'posting:default:visibility'} = $visibility; + $this->{'posting:default:sensitive'} = $sensitive; + $this->{'posting:default:language'} = $language; + $this->{'reading:expand:media'} = $media; + $this->{'reading:expand:spoilers'} = $spoilers; + } +} diff --git a/static/dbstructure.config.php b/static/dbstructure.config.php index 247552761f..052f73b9cf 100644 --- a/static/dbstructure.config.php +++ b/static/dbstructure.config.php @@ -55,7 +55,7 @@ use Friendica\Database\DBA; if (!defined('DB_UPDATE_VERSION')) { - define('DB_UPDATE_VERSION', 1414); + define('DB_UPDATE_VERSION', 1415); } return [ @@ -426,6 +426,22 @@ return [ "gsid" => ["gsid"] ] ], + "application" => [ + "comment" => "OAuth application", + "fields" => [ + "id" => ["type" => "int unsigned", "not null" => "1", "extra" => "auto_increment", "primary" => "1", "comment" => "generated index"], + "client_id" => ["type" => "varchar(64)", "not null" => "1", "comment" => ""], + "client_secret" => ["type" => "varchar(64)", "not null" => "1", "comment" => ""], + "name" => ["type" => "varchar(255)", "not null" => "1", "comment" => ""], + "redirect_uri" => ["type" => "varchar(255)", "not null" => "1", "comment" => ""], + "website" => ["type" => "varchar(255)", "comment" => ""], + "scopes" => ["type" => "varchar(255)", "comment" => ""], + ], + "indexes" => [ + "PRIMARY" => ["id"], + "client_id" => ["UNIQUE", "client_id"] + ] + ], "attach" => [ "comment" => "file attachments", "fields" => [ diff --git a/static/routes.config.php b/static/routes.config.php index ddc755ec18..5fbcffbd7c 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -62,7 +62,7 @@ return [ '/accounts/{id:\d+}/statuses' => [Module\Api\Mastodon\Accounts\Statuses::class, [R::GET ]], '/accounts/{id:\d+}/followers' => [Module\Api\Mastodon\Accounts\Followers::class, [R::GET ]], '/accounts/{id:\d+}/following' => [Module\Api\Mastodon\Accounts\Following::class, [R::GET ]], - '/accounts/{id:\d+}/lists' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo + '/accounts/{id:\d+}/lists' => [Module\Api\Mastodon\Accounts\Lists::class, [R::GET ]], '/accounts/{id:\d+}/identity_proofs' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented '/accounts/{id:\d+}/follow' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], '/accounts/{id:\d+}/unfollow' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], @@ -74,7 +74,7 @@ return [ '/accounts/{id:\d+}/unpin' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not implemented '/accounts/{id:\d+}/note' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], '/accounts/relationships' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo - '/accounts/search' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo + '/accounts/search' => [Module\Api\Mastodon\Accounts\Search::class, [R::GET ]], '/accounts/verify_credentials' => [Module\Api\Mastodon\Accounts\VerifyCredentials::class, [R::GET ]], '/accounts/update_credentials' => [Module\Api\Mastodon\Unimplemented::class, [R::PATCH ]], '/admin/accounts' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented @@ -86,13 +86,13 @@ return [ '/announcements' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented '/announcements/{id:\d+}/dismiss' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not implemented '/announcements/{id:\d+}/reactions/{name}' => [Module\Api\Mastodon\Unimplemented::class, [R::PUT, R::DELETE]], // not implemented - '/apps' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], + '/apps' => [Module\Api\Mastodon\Apps::class, [ R::POST]], '/apps/verify_credentials' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], '/blocks' => [Module\Api\Mastodon\Blocks::class, [R::GET ]], '/bookmarks' => [Module\Api\Mastodon\Bookmarks::class, [R::GET ]], - '/conversations' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo - '/conversations/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::DELETE ]], - '/conversations/{id:\d+}/read' => [Module\Api\Mastodon\Unimplemented::class, [R::POST ]], + '/conversations' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented + '/conversations/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::DELETE ]], // not implemented + '/conversations/{id:\d+}/read' => [Module\Api\Mastodon\Unimplemented::class, [R::POST ]], // not implemented '/custom_emojis' => [Module\Api\Mastodon\CustomEmojis::class, [R::GET ]], '/domain_blocks' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::POST, R::DELETE]], // not implemented '/directory' => [Module\Api\Mastodon\Directory::class, [R::GET ]], @@ -108,20 +108,20 @@ return [ '/instance' => [Module\Api\Mastodon\Instance::class, [R::GET ]], '/instance/activity' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo '/instance/peers' => [Module\Api\Mastodon\Instance\Peers::class, [R::GET ]], - '/lists' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::POST]], // @todo - '/lists/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::PUT, R::DELETE]], // @todo - '/lists/{id:\d+}/accounts' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::POST, R::DELETE]], // @todo + '/lists' => [Module\Api\Mastodon\Lists::class, [R::GET, R::POST]], + '/lists/{id:\d+}' => [Module\Api\Mastodon\Lists::class, [R::GET, R::PUT, R::DELETE]], + '/lists/{id:\d+}/accounts' => [Module\Api\Mastodon\Lists\Accounts::class, [R::GET, R::POST, R::DELETE]], '/markers' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::POST]], // not implemented '/media' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], - '/media/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::PUT]], // @todo + '/media/{id:\d+}' => [Module\Api\Mastodon\Media::class, [R::GET, R::PUT]], '/mutes' => [Module\Api\Mastodon\Mutes::class, [R::GET ]], - '/notifications' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo - '/notifications/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo + '/notifications' => [Module\Api\Mastodon\Notifications::class, [R::GET ]], + '/notifications/{id:\d+}' => [Module\Api\Mastodon\Notifications::class, [R::GET ]], '/notifications/clear' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], '/notifications/{id:\d+}/dismiss' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], '/polls/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented '/polls/{id:\d+}/votes' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not implemented - '/preferences' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // @todo + '/preferences' => [Module\Api\Mastodon\Preferences::class, [R::GET ]], '/reports' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // not implemented '/scheduled_statuses' => [Module\Api\Mastodon\Unimplemented::class, [R::GET ]], // not implemented '/scheduled_statuses/{id:\d+}' => [Module\Api\Mastodon\Unimplemented::class, [R::GET, R::PUT, R::DELETE]], // not implemented