3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Module\Api\Mastodon;
24 use Friendica\Content\PageInfo;
25 use Friendica\Content\Text\BBCode;
26 use Friendica\Content\Text\Markdown;
27 use Friendica\Core\Protocol;
28 use Friendica\Core\System;
29 use Friendica\Core\Worker;
30 use Friendica\Database\DBA;
32 use Friendica\Model\Contact;
33 use Friendica\Model\Group;
34 use Friendica\Model\Item;
35 use Friendica\Model\Photo;
36 use Friendica\Model\Post;
37 use Friendica\Model\Tag;
38 use Friendica\Model\User;
39 use Friendica\Module\BaseApi;
40 use Friendica\Network\HTTPException;
41 use Friendica\Protocol\Activity;
42 use Friendica\Util\DateTimeFormat;
43 use Friendica\Util\Images;
46 * @see https://docs.joinmastodon.org/methods/statuses/
48 class Statuses extends BaseApi
50 public function put(array $request = [])
52 self::checkAllowedScope(self::SCOPE_WRITE);
53 $uid = self::getCurrentUserID();
55 $request = $this->getRequest([
56 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
57 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
58 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
59 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
60 'language' => '', // ISO 639 language code for this status.
64 $owner = User::getOwnerDataById($uid);
68 'uri-id' => $this->parameters['id'],
69 'contact-id' => $owner['id'],
70 'author-id' => Contact::getPublicIdByUserId($uid),
74 $post = Post::selectFirst(['uri-id', 'id', 'gravity', 'uid', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'network'], $condition);
75 if (empty($post['id'])) {
76 throw new HTTPException\NotFoundException('Item with URI ID ' . $this->parameters['id'] . ' not found for user ' . $uid . '.');
79 // The imput is defined as text. So we can use Markdown for some enhancements
80 $body = Markdown::toBBCode($request['status']);
82 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
83 $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
87 $item['uid'] = $post['uid'];
88 $item['body'] = $body;
89 $item['network'] = $post['network'];
90 $item['app'] = $this->getApp();
92 if (!empty($request['language'])) {
93 $item['language'] = json_encode([$request['language'] => 1]);
96 if ($post['gravity'] == Item::GRAVITY_PARENT) {
97 $item['title'] = $request['friendica']['title'] ?? '';
100 $spoiler_text = $request['spoiler_text'];
102 if (!empty($spoiler_text)) {
103 if (!isset($request['friendica']['title']) && $post['gravity'] == Item::GRAVITY_PARENT && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
104 $item['title'] = $spoiler_text;
106 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $spoiler_text . "[/abstract]\n" . $item['body'];
107 $item['content-warning'] = BBCode::toPlaintext($spoiler_text);
111 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
113 if (!empty($request['media_ids'])) {
115 The provided ids in the request value consists of these two sources:
116 - The id in the "photo" table for newly uploaded media
117 - The id in the "post-media" table for already attached media
119 Because of this we have to add all media that isn't already attached.
120 Also we have to delete all media that isn't provided anymore.
122 There is a possible situation where the newly uploaded media
123 could have the same id as an existing, but deleted media.
125 We can't do anything about this, but the probability for this is extremely low.
128 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
130 foreach ($request['media_ids'] as $media) {
131 if (!in_array($media, $existing_media)) {
132 $media_ids[] = $media;
136 foreach ($existing_media as $media) {
137 if (!in_array($media, $request['media_ids'])) {
138 Post\Media::deleteById($media);
142 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
144 foreach ($item['attachments'] as $attachment) {
145 $attachment['uri-id'] = $post['uri-id'];
146 Post\Media::insert($attachment);
148 unset($item['attachments']);
150 if (!Item::isValid($item)) {
151 throw new \Exception('Missing parameters in definition');
154 Item::update($item, ['id' => $post['id']]);
156 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
157 Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
159 // Store tags from the body if this hadn't been handled previously in the protocol classes
161 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
163 Item::updateDisplayCache($post['uri-id']);
165 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
168 protected function post(array $request = [])
170 self::checkAllowedScope(self::SCOPE_WRITE);
171 $uid = self::getCurrentUserID();
173 $request = $this->getRequest([
174 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
175 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
176 'poll' => [], // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
177 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
178 'quote_id' => 0, // ID of the message to quote
179 'sensitive' => false, // Mark status and attached media as sensitive?
180 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
181 'visibility' => '', // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
182 'scheduled_at' => '', // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future.
183 'language' => '', // ISO 639 language code for this status.
184 'friendica' => [], // Friendica extensions to the standard Mastodon API spec
187 $owner = User::getOwnerDataById($uid);
189 // The imput is defined as text. So we can use Markdown for some enhancements
190 $body = Markdown::toBBCode($request['status']);
192 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
193 $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
197 $item['network'] = Protocol::DFRN;
199 $item['verb'] = Activity::POST;
200 $item['contact-id'] = $owner['id'];
201 $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
203 $item['body'] = $body;
204 $item['app'] = $this->getApp();
206 switch ($request['visibility']) {
208 $item['allow_cid'] = '';
209 $item['allow_gid'] = '';
210 $item['deny_cid'] = '';
211 $item['deny_gid'] = '';
212 $item['private'] = Item::PUBLIC;
215 $item['allow_cid'] = '';
216 $item['allow_gid'] = '';
217 $item['deny_cid'] = '';
218 $item['deny_gid'] = '';
219 $item['private'] = Item::UNLISTED;
222 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
223 $item['allow_cid'] = $owner['allow_cid'];
224 $item['allow_gid'] = $owner['allow_gid'];
225 $item['deny_cid'] = $owner['deny_cid'];
226 $item['deny_gid'] = $owner['deny_gid'];
228 $item['allow_cid'] = '';
229 $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
230 $item['deny_cid'] = '';
231 $item['deny_gid'] = '';
233 $item['private'] = Item::PRIVATE;
236 $item['private'] = Item::PRIVATE;
237 // The permissions are assigned in "expandTags"
240 if (is_numeric($request['visibility']) && Group::exists($request['visibility'], $uid)) {
241 $item['allow_cid'] = '';
242 $item['allow_gid'] = '<' . $request['visibility'] . '>';
243 $item['deny_cid'] = '';
244 $item['deny_gid'] = '';
246 $item['allow_cid'] = $owner['allow_cid'];
247 $item['allow_gid'] = $owner['allow_gid'];
248 $item['deny_cid'] = $owner['deny_cid'];
249 $item['deny_gid'] = $owner['deny_gid'];
252 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
253 $item['private'] = Item::PRIVATE;
254 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
255 $item['private'] = Item::UNLISTED;
257 $item['private'] = Item::PUBLIC;
262 if (!empty($request['language'])) {
263 $item['language'] = json_encode([$request['language'] => 1]);
266 if ($request['in_reply_to_id']) {
267 $parent = Post::selectFirst(['uri', 'private'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
269 $item['thr-parent'] = $parent['uri'];
270 $item['gravity'] = Item::GRAVITY_COMMENT;
271 $item['object-type'] = Activity\ObjectType::COMMENT;
273 if (in_array($parent['private'], [Item::UNLISTED, Item::PUBLIC]) && ($item['private'] == Item::PRIVATE)) {
274 throw new HTTPException\NotImplementedException('Private replies for public posts are not implemented.');
277 self::checkThrottleLimit();
279 $item['gravity'] = Item::GRAVITY_PARENT;
280 $item['object-type'] = Activity\ObjectType::NOTE;
283 if ($request['quote_id']) {
284 if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
285 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
287 $item['quote-uri-id'] = $request['quote_id'];
290 $item['title'] = $request['friendica']['title'] ?? '';
292 if (!empty($request['spoiler_text'])) {
293 if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
294 $item['title'] = $request['spoiler_text'];
296 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
300 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
302 if (!empty($request['media_ids'])) {
303 $item = $this->storeMediaIds($request['media_ids'], $item);
306 if (!empty($request['scheduled_at'])) {
307 $item['guid'] = Item::guid($item, true);
308 $item['uri'] = Item::newURI($item['guid']);
309 $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
311 DI::mstdnError()->InternalError();
313 System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
316 $id = Item::insert($item, true);
318 $item = Post::selectFirst(['uri-id'], ['id' => $id]);
319 if (!empty($item['uri-id'])) {
320 System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
324 DI::mstdnError()->InternalError();
327 protected function delete(array $request = [])
329 self::checkAllowedScope(self::SCOPE_READ);
330 $uid = self::getCurrentUserID();
332 if (empty($this->parameters['id'])) {
333 DI::mstdnError()->UnprocessableEntity();
336 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
337 if (empty($item['id'])) {
338 DI::mstdnError()->RecordNotFound();
341 if (!Item::markForDeletionById($item['id'])) {
342 DI::mstdnError()->RecordNotFound();
345 System::jsonExit([]);
349 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
351 protected function rawContent(array $request = [])
353 $uid = self::getCurrentUserID();
355 if (empty($this->parameters['id'])) {
356 DI::mstdnError()->UnprocessableEntity();
359 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
362 private function getApp(): string
364 if (!empty(self::getCurrentApplication()['name'])) {
365 return self::getCurrentApplication()['name'];
372 * Store provided media ids in the item array and adjust permissions
374 * @param array $media_ids
378 private function storeMediaIds(array $media_ids, array $item): array
380 $item['object-type'] = Activity\ObjectType::IMAGE;
381 $item['post-type'] = Item::PT_IMAGE;
382 $item['attachments'] = [];
384 foreach ($media_ids as $id) {
385 $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
386 WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
387 ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
393 Photo::setPermissionForRessource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
395 $ressources[] = $media[0]['resource-id'];
396 $phototypes = Images::supportedTypes();
397 $ext = $phototypes[$media[0]['type']];
399 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
400 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
401 'size' => $media[0]['datasize'],
402 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
403 'description' => $media[0]['desc'] ?? '',
404 'width' => $media[0]['width'],
405 'height' => $media[0]['height']];
407 if (count($media) > 1) {
408 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
409 $attachment['preview-width'] = $media[1]['width'];
410 $attachment['preview-height'] = $media[1]['height'];
412 $item['attachments'][] = $attachment;