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', 'verb', '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['gravity'] = $post['gravity'];
91 $item['verb'] = $post['verb'];
92 $item['app'] = $this->getApp();
94 if (!empty($request['language'])) {
95 $item['language'] = json_encode([$request['language'] => 1]);
98 if ($post['gravity'] == Item::GRAVITY_PARENT) {
99 $item['title'] = $request['friendica']['title'] ?? '';
102 $spoiler_text = $request['spoiler_text'];
104 if (!empty($spoiler_text)) {
105 if (!isset($request['friendica']['title']) && $post['gravity'] == Item::GRAVITY_PARENT && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
106 $item['title'] = $spoiler_text;
108 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $spoiler_text . "[/abstract]\n" . $item['body'];
109 $item['content-warning'] = BBCode::toPlaintext($spoiler_text);
113 $item = DI::contentItem()->expandTags($item);
116 The provided ids in the request value consists of these two sources:
117 - The id in the "photo" table for newly uploaded media
118 - The id in the "post-media" table for already attached media
120 Because of this we have to add all media that isn't already attached.
121 Also we have to delete all media that isn't provided anymore.
123 There is a possible situation where the newly uploaded media
124 could have the same id as an existing, but deleted media.
126 We can't do anything about this, but the probability for this is extremely low.
129 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
131 foreach ($request['media_ids'] as $media) {
132 if (!in_array($media, $existing_media)) {
133 $media_ids[] = $media;
137 foreach ($existing_media as $media) {
138 if (!in_array($media, $request['media_ids'])) {
139 Post\Media::deleteById($media);
143 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
145 foreach ($item['attachments'] as $attachment) {
146 $attachment['uri-id'] = $post['uri-id'];
147 Post\Media::insert($attachment);
149 unset($item['attachments']);
151 if (!Item::isValid($item)) {
152 throw new \Exception('Missing parameters in definition');
155 // Link Preview Attachment Processing
156 Post\Media::deleteByURIId($post['uri-id'], [Post\Media::HTML]);
158 Item::update($item, ['id' => $post['id']]);
160 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
161 Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
163 // Store tags from the body if this hadn't been handled previously in the protocol classes
165 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
167 Item::updateDisplayCache($post['uri-id']);
169 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
172 protected function post(array $request = [])
174 self::checkAllowedScope(self::SCOPE_WRITE);
175 $uid = self::getCurrentUserID();
177 $request = $this->getRequest([
178 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
179 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
180 'poll' => [], // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
181 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
182 'quote_id' => 0, // ID of the message to quote
183 'sensitive' => false, // Mark status and attached media as sensitive?
184 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
185 'visibility' => '', // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
186 '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.
187 'language' => '', // ISO 639 language code for this status.
188 'friendica' => [], // Friendica extensions to the standard Mastodon API spec
191 $owner = User::getOwnerDataById($uid);
193 // The imput is defined as text. So we can use Markdown for some enhancements
194 $body = Markdown::toBBCode($request['status']);
196 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
197 $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
201 $item['network'] = Protocol::DFRN;
203 $item['verb'] = Activity::POST;
204 $item['contact-id'] = $owner['id'];
205 $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
207 $item['body'] = $body;
208 $item['app'] = $this->getApp();
210 switch ($request['visibility']) {
212 $item['allow_cid'] = '';
213 $item['allow_gid'] = '';
214 $item['deny_cid'] = '';
215 $item['deny_gid'] = '';
216 $item['private'] = Item::PUBLIC;
219 $item['allow_cid'] = '';
220 $item['allow_gid'] = '';
221 $item['deny_cid'] = '';
222 $item['deny_gid'] = '';
223 $item['private'] = Item::UNLISTED;
226 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
227 $item['allow_cid'] = $owner['allow_cid'];
228 $item['allow_gid'] = $owner['allow_gid'];
229 $item['deny_cid'] = $owner['deny_cid'];
230 $item['deny_gid'] = $owner['deny_gid'];
232 $item['allow_cid'] = '';
233 $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
234 $item['deny_cid'] = '';
235 $item['deny_gid'] = '';
237 $item['private'] = Item::PRIVATE;
240 $item['private'] = Item::PRIVATE;
241 // The permissions are assigned in "expandTags"
244 if (is_numeric($request['visibility']) && Group::exists($request['visibility'], $uid)) {
245 $item['allow_cid'] = '';
246 $item['allow_gid'] = '<' . $request['visibility'] . '>';
247 $item['deny_cid'] = '';
248 $item['deny_gid'] = '';
250 $item['allow_cid'] = $owner['allow_cid'];
251 $item['allow_gid'] = $owner['allow_gid'];
252 $item['deny_cid'] = $owner['deny_cid'];
253 $item['deny_gid'] = $owner['deny_gid'];
256 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
257 $item['private'] = Item::PRIVATE;
258 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
259 $item['private'] = Item::UNLISTED;
261 $item['private'] = Item::PUBLIC;
266 if (!empty($request['language'])) {
267 $item['language'] = json_encode([$request['language'] => 1]);
270 if ($request['in_reply_to_id']) {
271 $parent = Post::selectOriginal(['uri'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
272 if (empty($parent)) {
273 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['in_reply_to_id'] . ' not found for user ' . $uid . '.');
276 $item['thr-parent'] = $parent['uri'];
277 $item['gravity'] = Item::GRAVITY_COMMENT;
278 $item['object-type'] = Activity\ObjectType::COMMENT;
280 self::checkThrottleLimit();
282 $item['gravity'] = Item::GRAVITY_PARENT;
283 $item['object-type'] = Activity\ObjectType::NOTE;
286 if ($request['quote_id']) {
287 if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
288 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
290 $item['quote-uri-id'] = $request['quote_id'];
293 $item['title'] = $request['friendica']['title'] ?? '';
295 if (!empty($request['spoiler_text'])) {
296 if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
297 $item['title'] = $request['spoiler_text'];
299 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
303 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
305 if (!empty($request['media_ids'])) {
306 $item = $this->storeMediaIds($request['media_ids'], $item);
309 if (!empty($request['scheduled_at'])) {
310 $item['guid'] = Item::guid($item, true);
311 $item['uri'] = Item::newURI($item['guid']);
312 $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
314 DI::mstdnError()->InternalError();
316 System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
319 $id = Item::insert($item, true);
321 $item = Post::selectFirst(['uri-id'], ['id' => $id]);
322 if (!empty($item['uri-id'])) {
323 System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
327 DI::mstdnError()->InternalError();
330 protected function delete(array $request = [])
332 self::checkAllowedScope(self::SCOPE_READ);
333 $uid = self::getCurrentUserID();
335 if (empty($this->parameters['id'])) {
336 DI::mstdnError()->UnprocessableEntity();
339 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
340 if (empty($item['id'])) {
341 DI::mstdnError()->RecordNotFound();
344 if (!Item::markForDeletionById($item['id'])) {
345 DI::mstdnError()->RecordNotFound();
348 System::jsonExit([]);
352 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
354 protected function rawContent(array $request = [])
356 $uid = self::getCurrentUserID();
358 if (empty($this->parameters['id'])) {
359 DI::mstdnError()->UnprocessableEntity();
362 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
365 private function getApp(): string
367 if (!empty(self::getCurrentApplication()['name'])) {
368 return self::getCurrentApplication()['name'];
375 * Store provided media ids in the item array and adjust permissions
377 * @param array $media_ids
381 private function storeMediaIds(array $media_ids, array $item): array
383 $item['object-type'] = Activity\ObjectType::IMAGE;
384 $item['post-type'] = Item::PT_IMAGE;
385 $item['attachments'] = [];
387 foreach ($media_ids as $id) {
388 $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
389 WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
390 ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
396 Photo::setPermissionForResource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
398 $phototypes = Images::supportedTypes();
399 $ext = $phototypes[$media[0]['type']];
401 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
402 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
403 'size' => $media[0]['datasize'],
404 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
405 'description' => $media[0]['desc'] ?? '',
406 'width' => $media[0]['width'],
407 'height' => $media[0]['height']];
409 if (count($media) > 1) {
410 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
411 $attachment['preview-width'] = $media[1]['width'];
412 $attachment['preview-height'] = $media[1]['height'];
414 $item['attachments'][] = $attachment;