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');
114 The provided ids in the request value consists of these two sources:
115 - The id in the "photo" table for newly uploaded media
116 - The id in the "post-media" table for already attached media
118 Because of this we have to add all media that isn't already attached.
119 Also we have to delete all media that isn't provided anymore.
121 There is a possible situation where the newly uploaded media
122 could have the same id as an existing, but deleted media.
124 We can't do anything about this, but the probability for this is extremely low.
127 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
129 foreach ($request['media_ids'] as $media) {
130 if (!in_array($media, $existing_media)) {
131 $media_ids[] = $media;
135 foreach ($existing_media as $media) {
136 if (!in_array($media, $request['media_ids'])) {
137 Post\Media::deleteById($media);
141 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
143 foreach ($item['attachments'] as $attachment) {
144 $attachment['uri-id'] = $post['uri-id'];
145 Post\Media::insert($attachment);
147 unset($item['attachments']);
149 if (!Item::isValid($item)) {
150 throw new \Exception('Missing parameters in definition');
153 // Link Preview Attachment Processing
154 Post\Media::deleteByURIId($post['uri-id'], [Post\Media::HTML]);
156 Item::update($item, ['id' => $post['id']]);
158 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
159 Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
161 // Store tags from the body if this hadn't been handled previously in the protocol classes
163 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
165 Item::updateDisplayCache($post['uri-id']);
167 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
170 protected function post(array $request = [])
172 self::checkAllowedScope(self::SCOPE_WRITE);
173 $uid = self::getCurrentUserID();
175 $request = $this->getRequest([
176 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
177 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
178 'poll' => [], // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
179 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
180 'quote_id' => 0, // ID of the message to quote
181 'sensitive' => false, // Mark status and attached media as sensitive?
182 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
183 'visibility' => '', // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
184 'scheduled_at' => '', // ISO 8601 Datetime at which to schedule a status. Providing this parameter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future.
185 'language' => '', // ISO 639 language code for this status.
186 'friendica' => [], // Friendica extensions to the standard Mastodon API spec
189 $owner = User::getOwnerDataById($uid);
191 // The imput is defined as text. So we can use Markdown for some enhancements
192 $body = Markdown::toBBCode($request['status']);
194 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
195 $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
199 $item['network'] = Protocol::DFRN;
201 $item['verb'] = Activity::POST;
202 $item['contact-id'] = $owner['id'];
203 $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
205 $item['body'] = $body;
206 $item['app'] = $this->getApp();
208 switch ($request['visibility']) {
210 $item['allow_cid'] = '';
211 $item['allow_gid'] = '';
212 $item['deny_cid'] = '';
213 $item['deny_gid'] = '';
214 $item['private'] = Item::PUBLIC;
217 $item['allow_cid'] = '';
218 $item['allow_gid'] = '';
219 $item['deny_cid'] = '';
220 $item['deny_gid'] = '';
221 $item['private'] = Item::UNLISTED;
224 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
225 $item['allow_cid'] = $owner['allow_cid'];
226 $item['allow_gid'] = $owner['allow_gid'];
227 $item['deny_cid'] = $owner['deny_cid'];
228 $item['deny_gid'] = $owner['deny_gid'];
230 $item['allow_cid'] = '';
231 $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
232 $item['deny_cid'] = '';
233 $item['deny_gid'] = '';
235 $item['private'] = Item::PRIVATE;
238 $item['private'] = Item::PRIVATE;
239 // The permissions are assigned in "expandTags"
242 if (is_numeric($request['visibility']) && Group::exists($request['visibility'], $uid)) {
243 $item['allow_cid'] = '';
244 $item['allow_gid'] = '<' . $request['visibility'] . '>';
245 $item['deny_cid'] = '';
246 $item['deny_gid'] = '';
248 $item['allow_cid'] = $owner['allow_cid'];
249 $item['allow_gid'] = $owner['allow_gid'];
250 $item['deny_cid'] = $owner['deny_cid'];
251 $item['deny_gid'] = $owner['deny_gid'];
254 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
255 $item['private'] = Item::PRIVATE;
256 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
257 $item['private'] = Item::UNLISTED;
259 $item['private'] = Item::PUBLIC;
264 if (!empty($request['language'])) {
265 $item['language'] = json_encode([$request['language'] => 1]);
268 if ($request['in_reply_to_id']) {
269 $parent = Post::selectFirst(['uri'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
270 if (empty($parent)) {
271 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['in_reply_to_id'] . ' not found for user ' . $uid . '.');
274 $item['thr-parent'] = $parent['uri'];
275 $item['gravity'] = Item::GRAVITY_COMMENT;
276 $item['object-type'] = Activity\ObjectType::COMMENT;
278 self::checkThrottleLimit();
280 $item['gravity'] = Item::GRAVITY_PARENT;
281 $item['object-type'] = Activity\ObjectType::NOTE;
284 if ($request['quote_id']) {
285 if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
286 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
288 $item['quote-uri-id'] = $request['quote_id'];
291 $item['title'] = $request['friendica']['title'] ?? '';
293 if (!empty($request['spoiler_text'])) {
294 if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
295 $item['title'] = $request['spoiler_text'];
297 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
301 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
303 if (!empty($request['media_ids'])) {
304 $item = $this->storeMediaIds($request['media_ids'], $item);
307 if (!empty($request['scheduled_at'])) {
308 $item['guid'] = Item::guid($item, true);
309 $item['uri'] = Item::newURI($item['guid']);
310 $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
312 DI::mstdnError()->InternalError();
314 System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
317 $id = Item::insert($item, true);
319 $item = Post::selectFirst(['uri-id'], ['id' => $id]);
320 if (!empty($item['uri-id'])) {
321 System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
325 DI::mstdnError()->InternalError();
328 protected function delete(array $request = [])
330 self::checkAllowedScope(self::SCOPE_READ);
331 $uid = self::getCurrentUserID();
333 if (empty($this->parameters['id'])) {
334 DI::mstdnError()->UnprocessableEntity();
337 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
338 if (empty($item['id'])) {
339 DI::mstdnError()->RecordNotFound();
342 if (!Item::markForDeletionById($item['id'])) {
343 DI::mstdnError()->RecordNotFound();
346 System::jsonExit([]);
350 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
352 protected function rawContent(array $request = [])
354 $uid = self::getCurrentUserID();
356 if (empty($this->parameters['id'])) {
357 DI::mstdnError()->UnprocessableEntity();
360 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
363 private function getApp(): string
365 if (!empty(self::getCurrentApplication()['name'])) {
366 return self::getCurrentApplication()['name'];
373 * Store provided media ids in the item array and adjust permissions
375 * @param array $media_ids
379 private function storeMediaIds(array $media_ids, array $item): array
381 $item['object-type'] = Activity\ObjectType::IMAGE;
382 $item['post-type'] = Item::PT_IMAGE;
383 $item['attachments'] = [];
385 foreach ($media_ids as $id) {
386 $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
387 WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
388 ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
394 Photo::setPermissionForRessource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
396 $ressources[] = $media[0]['resource-id'];
397 $phototypes = Images::supportedTypes();
398 $ext = $phototypes[$media[0]['type']];
400 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
401 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
402 'size' => $media[0]['datasize'],
403 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
404 'description' => $media[0]['desc'] ?? '',
405 'width' => $media[0]['width'],
406 'height' => $media[0]['height']];
408 if (count($media) > 1) {
409 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
410 $attachment['preview-width'] = $media[1]['width'];
411 $attachment['preview-height'] = $media[1]['height'];
413 $item['attachments'][] = $attachment;