From 1cc341033f28f35b66400a394d1031c2e885a302 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 15 May 2021 22:40:57 +0000 Subject: [PATCH] API: We now can post statuses via API --- src/Content/Text/BBCode.php | 26 +++++ src/Module/Api/Mastodon/Filters.php | 40 +++++++ src/Module/Api/Mastodon/Statuses.php | 153 ++++++++++++++++++++++++++- src/Module/BaseApi.php | 43 ++++++-- 4 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 src/Module/Api/Mastodon/Filters.php diff --git a/src/Content/Text/BBCode.php b/src/Content/Text/BBCode.php index af423a676..32cd818ca 100644 --- a/src/Content/Text/BBCode.php +++ b/src/Content/Text/BBCode.php @@ -2115,6 +2115,32 @@ class BBCode return array_unique($ret); } + /** + * Expand tags to URLs + * + * @param string $body + * @return string body with expanded tags + */ + public static function expandTags(string $body) + { + return preg_replace_callback("/([!#@])([^\^ \x0D\x0A,;:?\']*[^\^ \x0D\x0A,;:?!\'.])/", + function ($match) { + switch ($match[1]) { + case '!': + case '@': + $contact = Contact::getByURL($match[2]); + if (!empty($contact)) { + return $match[1] . '[url=' . $contact['url'] . ']' . $contact['name'] . '[/url]'; + } else { + return $match[1] . $match[2]; + } + break; + case '#': + return $match[1] . '[url=' . 'https://' . DI::baseUrl() . '/search?tag=' . $match[2] . ']' . $match[2] . '[/url]'; + } + }, $body); + } + /** * Perform a custom function on a text after having escaped blocks enclosed in the provided tag list. * diff --git a/src/Module/Api/Mastodon/Filters.php b/src/Module/Api/Mastodon/Filters.php new file mode 100644 index 000000000..cdcefc97f --- /dev/null +++ b/src/Module/Api/Mastodon/Filters.php @@ -0,0 +1,40 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/filters/ + */ +class Filters extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + System::jsonError(404, ['error' => 'Record not found']); + } +} diff --git a/src/Module/Api/Mastodon/Statuses.php b/src/Module/Api/Mastodon/Statuses.php index 974c58bd3..cb873b83d 100644 --- a/src/Module/Api/Mastodon/Statuses.php +++ b/src/Module/Api/Mastodon/Statuses.php @@ -21,12 +21,19 @@ namespace Friendica\Module\Api\Mastodon; -use Friendica\Core\Logger; +use Friendica\Content\Text\BBCode; +use Friendica\Content\Text\Markdown; use Friendica\Core\System; +use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Model\Group; use Friendica\Model\Item; use Friendica\Model\Post; +use Friendica\Model\User; use Friendica\Module\BaseApi; +use Friendica\Protocol\Activity; +use Friendica\Util\Images; /** * @see https://docs.joinmastodon.org/methods/statuses/ @@ -35,8 +42,150 @@ class Statuses extends BaseApi { public static function post(array $parameters = []) { + self::login(); + $uid = self::getCurrentUserID(); + $data = self::getJsonPostData(); - self::unsupported('post'); + + $status = $data['status'] ?? ''; + $media_ids = $data['media_ids'] ?? []; + $in_reply_to_id = $data['in_reply_to_id'] ?? 0; + $sensitive = $data['sensitive'] ?? false; // @todo Possibly trigger "nsfw" flag? + $spoiler_text = $data['spoiler_text'] ?? ''; + $visibility = $data['visibility'] ?? ''; + $scheduled_at = $data['scheduled_at'] ?? ''; // Currently unsupported, but maybe in the future + $language = $data['language'] ?? ''; + + $owner = User::getOwnerDataById($uid); + + // The imput is defined as text. So we can use Markdown for some enhancements + $body = Markdown::toBBCode($status); + + $body = BBCode::expandTags($body); + + $item = []; + $item['uid'] = $uid; + $item['verb'] = Activity::POST; + $item['contact-id'] = $owner['id']; + $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid); + $item['title'] = $spoiler_text; + $item['body'] = $body; + + if (!empty(self::getCurrentApplication()['name'])) { + $item['app'] = self::getCurrentApplication()['name']; + } + + if (empty($item['app'])) { + $item['app'] = 'API'; + } + + switch ($visibility) { + case 'public': + $item['allow_cid'] = ''; + $item['allow_gid'] = ''; + $item['deny_cid'] = ''; + $item['deny_gid'] = ''; + $item['private'] = Item::PUBLIC; + break; + case 'unlisted': + $item['allow_cid'] = ''; + $item['allow_gid'] = ''; + $item['deny_cid'] = ''; + $item['deny_gid'] = ''; + $item['private'] = Item::UNLISTED; + break; + case 'private': + if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) { + $item['allow_cid'] = $owner['allow_cid']; + $item['allow_gid'] = $owner['allow_gid']; + $item['deny_cid'] = $owner['deny_cid']; + $item['deny_gid'] = $owner['deny_gid']; + } else { + $item['allow_cid'] = ''; + $item['allow_gid'] = [Group::FOLLOWERS]; + $item['deny_cid'] = ''; + $item['deny_gid'] = ''; + } + $item['private'] = Item::PRIVATE; + break; + case 'direct': + // Direct messages are currently unsupported + DI::mstdnError()->InternalError('Direct messages are currently unsupported'); + break; + default: + $item['allow_cid'] = $owner['allow_cid']; + $item['allow_gid'] = $owner['allow_gid']; + $item['deny_cid'] = $owner['deny_cid']; + $item['deny_gid'] = $owner['deny_gid']; + + if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) { + $item['private'] = Item::PRIVATE; + } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) { + $item['private'] = Item::UNLISTED; + } else { + $item['private'] = Item::PUBLIC; + } + break; + } + + if (!empty($language)) { + $item['language'] = json_encode([$language => 1]); + } + + if ($in_reply_to_id) { + $parent = Post::selectFirst(['uri'], ['uri-id' => $in_reply_to_id, 'uid' => [0, $uid]]); + $item['thr-parent'] = $parent['uri']; + $item['gravity'] = GRAVITY_COMMENT; + $item['object-type'] = Activity\ObjectType::COMMENT; + } else { + $item['gravity'] = GRAVITY_PARENT; + $item['object-type'] = Activity\ObjectType::NOTE; + } + + if (!empty($media_ids)) { + $item['object-type'] = Activity\ObjectType::IMAGE; + $item['post-type'] = Item::PT_IMAGE; + $item['attachments'] = []; + + foreach ($media_ids as $id) { + $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo` + WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ? + ORDER BY `photo`.`width` DESC LIMIT 2", $id, $uid)); + + if (empty($media)) { + continue; + } + + $ressources[] = $media[0]['resource-id']; + $phototypes = Images::supportedTypes(); + $ext = $phototypes[$media[0]['type']]; + + $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'], + 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext, + 'size' => $media[0]['datasize'], + 'name' => $media[0]['filename'] ?: $media[0]['resource-id'], + 'description' => $media[0]['desc'] ?? '', + 'width' => $media[0]['width'], + 'height' => $media[0]['height']]; + + if (count($media) > 1) { + $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext; + $attachment['preview-width'] = $media[1]['width']; + $attachment['preview-height'] = $media[1]['height']; + } + $item['attachments'][] = $attachment; + } + } + + $id = Item::insert($item, true); + if (!empty($id)) { + $item = Post::selectFirst(['uri-id'], ['id' => $id]); + if (!empty($item['uri-id'])) { + System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid)); + } + } + + DI::mstdnError()->InternalError(); } public static function delete(array $parameters = []) diff --git a/src/Module/BaseApi.php b/src/Module/BaseApi.php index f826f4ad2..6d70c1e6f 100644 --- a/src/Module/BaseApi.php +++ b/src/Module/BaseApi.php @@ -43,6 +43,10 @@ class BaseApi extends BaseModule * @var bool|int */ protected static $current_user_id; + /** + * @var array + */ + protected static $current_token = []; public static function init(array $parameters = []) { @@ -185,7 +189,12 @@ class BaseApi extends BaseModule protected static function login() { if (empty(self::$current_user_id)) { - self::$current_user_id = self::getUserByBearer(); + self::$current_token = self::getTokenByBearer(); + if (!empty(self::$current_token['uid'])) { + self::$current_user_id = self::$current_token['uid']; + } else { + self::$current_user_id = 0; + } } if (empty(self::$current_user_id)) { @@ -198,6 +207,16 @@ class BaseApi extends BaseModule return (bool)self::$current_user_id; } + /** + * Get current application + * + * @return array token + */ + protected static function getCurrentApplication() + { + return self::$current_token; + } + /** * Get current user id, returns 0 if not logged in * @@ -206,7 +225,13 @@ class BaseApi extends BaseModule protected static function getCurrentUserID() { if (empty(self::$current_user_id)) { - self::$current_user_id = self::getUserByBearer(); + self::$current_token = self::getTokenByBearer(); + if (!empty(self::$current_token['uid'])) { + self::$current_user_id = self::$current_token['uid']; + } else { + self::$current_user_id = 0; + } + } if (empty(self::$current_user_id)) { @@ -220,27 +245,27 @@ class BaseApi extends BaseModule } /** - * Get the user id via the Bearer token + * Get the user token via the Bearer token * - * @return int User-ID + * @return array User Token */ - private static function getUserByBearer() + private static function getTokenByBearer() { $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (substr($authorization, 0, 7) != 'Bearer ') { - return 0; + return []; } $bearer = trim(substr($authorization, 7)); $condition = ['access_token' => $bearer]; - $token = DBA::selectFirst('application-token', ['uid'], $condition); + $token = DBA::selectFirst('application-view', ['uid', 'id', 'name', 'website', 'created_at', 'read', 'write', 'follow'], $condition); if (!DBA::isResult($token)) { Logger::warning('Token not found', $condition); - return 0; + return []; } Logger::info('Token found', $token); - return $token['uid']; + return $token; } /**