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\User;
38 use Friendica\Module\BaseApi;
39 use Friendica\Network\HTTPException;
40 use Friendica\Protocol\Activity;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\Images;
45 * @see https://docs.joinmastodon.org/methods/statuses/
47 class Statuses extends BaseApi
49 public function put(array $request = [])
51 self::checkAllowedScope(self::SCOPE_WRITE);
52 $uid = self::getCurrentUserID();
54 $request = $this->getRequest([
55 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
56 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
57 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
58 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
59 'language' => '', // ISO 639 language code for this status.
63 $owner = User::getOwnerDataById($uid);
67 'uri-id' => $this->parameters['id'],
68 'contact-id' => $owner['id'],
69 'author-id' => Contact::getPublicIdByUserId($uid),
73 $post = Post::selectFirst(['uri-id', 'id', 'gravity', 'uid', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid'], $condition);
74 if (empty($post['id'])) {
75 throw new HTTPException\NotFoundException('Item with URI ID ' . $this->parameters['id'] . ' not found for user ' . $uid . '.');
78 // The imput is defined as text. So we can use Markdown for some enhancements
79 $item = ['body' => Markdown::toBBCode($request['status']), 'app' => $this->getApp(), 'title' => ''];
81 if (!empty($request['language'])) {
82 $item['language'] = json_encode([$request['language'] => 1]);
85 if ($post['gravity'] == Item::GRAVITY_PARENT) {
86 $item['title'] = $request['friendica']['title'] ?? '';
89 $spoiler_text = $request['spoiler_text'];
91 if (!empty($spoiler_text)) {
92 if (!isset($request['friendica']['title']) && $post['gravity'] == Item::GRAVITY_PARENT && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
93 $item['title'] = $spoiler_text;
95 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $spoiler_text . "[/abstract]\n" . $item['body'];
96 $item['content-warning'] = BBCode::toPlaintext($spoiler_text);
100 if (!empty($request['media_ids'])) {
102 The provided ids in the request value consists of these two sources:
103 - The id in the "photo" table for newly uploaded media
104 - The id in the "post-media" table for already attached media
106 Because of this we have to add all media that isn't already attached.
107 Also we have to delete all media that isn't provided anymore.
109 There is a possible situation where the newly uploaded media
110 could have the same id as an existing, but deleted media.
112 We can't do anything about this, but the probability for this is extremely low.
115 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
117 foreach ($request['media_ids'] as $media) {
118 if (!in_array($media, $existing_media)) {
119 $media_ids[] = $media;
123 foreach ($existing_media as $media) {
124 if (!in_array($media, $request['media_ids'])) {
125 Post\Media::deleteById($media);
129 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
131 foreach ($item['attachments'] as $attachment) {
132 $attachment['uri-id'] = $post['uri-id'];
133 Post\Media::insert($attachment);
135 unset($item['attachments']);
137 if (!Item::isValid($item)) {
138 throw new \Exception('Missing parameters in definitien');
141 Item::update($item, ['id' => $post['id']]);
142 Item::updateDisplayCache($post['uri-id']);
144 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
147 protected function post(array $request = [])
149 self::checkAllowedScope(self::SCOPE_WRITE);
150 $uid = self::getCurrentUserID();
152 $request = $this->getRequest([
153 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
154 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
155 'poll' => [], // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
156 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
157 'quote_id' => 0, // ID of the message to quote
158 'sensitive' => false, // Mark status and attached media as sensitive?
159 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
160 'visibility' => '', // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
161 '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.
162 'language' => '', // ISO 639 language code for this status.
163 'friendica' => [], // Friendica extensions to the standard Mastodon API spec
166 $owner = User::getOwnerDataById($uid);
168 // The imput is defined as text. So we can use Markdown for some enhancements
169 $body = Markdown::toBBCode($request['status']);
171 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
172 $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
176 $item['network'] = Protocol::DFRN;
178 $item['verb'] = Activity::POST;
179 $item['contact-id'] = $owner['id'];
180 $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
182 $item['body'] = $body;
183 $item['app'] = $this->getApp();
185 switch ($request['visibility']) {
187 $item['allow_cid'] = '';
188 $item['allow_gid'] = '';
189 $item['deny_cid'] = '';
190 $item['deny_gid'] = '';
191 $item['private'] = Item::PUBLIC;
194 $item['allow_cid'] = '';
195 $item['allow_gid'] = '';
196 $item['deny_cid'] = '';
197 $item['deny_gid'] = '';
198 $item['private'] = Item::UNLISTED;
201 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
202 $item['allow_cid'] = $owner['allow_cid'];
203 $item['allow_gid'] = $owner['allow_gid'];
204 $item['deny_cid'] = $owner['deny_cid'];
205 $item['deny_gid'] = $owner['deny_gid'];
207 $item['allow_cid'] = '';
208 $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
209 $item['deny_cid'] = '';
210 $item['deny_gid'] = '';
212 $item['private'] = Item::PRIVATE;
215 $item['private'] = Item::PRIVATE;
216 // The permissions are assigned in "expandTags"
219 if (is_numeric($request['visibility']) && Group::exists($request['visibility'], $uid)) {
220 $item['allow_cid'] = '';
221 $item['allow_gid'] = '<' . $request['visibility'] . '>';
222 $item['deny_cid'] = '';
223 $item['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'];
231 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
232 $item['private'] = Item::PRIVATE;
233 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
234 $item['private'] = Item::UNLISTED;
236 $item['private'] = Item::PUBLIC;
241 if (!empty($request['language'])) {
242 $item['language'] = json_encode([$request['language'] => 1]);
245 if ($request['in_reply_to_id']) {
246 $parent = Post::selectFirst(['uri', 'private'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
248 $item['thr-parent'] = $parent['uri'];
249 $item['gravity'] = Item::GRAVITY_COMMENT;
250 $item['object-type'] = Activity\ObjectType::COMMENT;
252 if (in_array($parent['private'], [Item::UNLISTED, Item::PUBLIC]) && ($item['private'] == Item::PRIVATE)) {
253 throw new HTTPException\NotImplementedException('Private replies for public posts are not implemented.');
256 self::checkThrottleLimit();
258 $item['gravity'] = Item::GRAVITY_PARENT;
259 $item['object-type'] = Activity\ObjectType::NOTE;
262 if ($request['quote_id']) {
263 if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
264 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
266 $item['quote-uri-id'] = $request['quote_id'];
269 $item['title'] = $request['friendica']['title'] ?? '';
271 if (!empty($request['spoiler_text'])) {
272 if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
273 $item['title'] = $request['spoiler_text'];
275 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
279 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
281 if (!empty($request['media_ids'])) {
282 $item = $this->storeMediaIds($request['media_ids'], $item);
285 if (!empty($request['scheduled_at'])) {
286 $item['guid'] = Item::guid($item, true);
287 $item['uri'] = Item::newURI($item['guid']);
288 $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
290 DI::mstdnError()->InternalError();
292 System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
295 $id = Item::insert($item, true);
297 $item = Post::selectFirst(['uri-id'], ['id' => $id]);
298 if (!empty($item['uri-id'])) {
299 System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
303 DI::mstdnError()->InternalError();
306 protected function delete(array $request = [])
308 self::checkAllowedScope(self::SCOPE_READ);
309 $uid = self::getCurrentUserID();
311 if (empty($this->parameters['id'])) {
312 DI::mstdnError()->UnprocessableEntity();
315 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
316 if (empty($item['id'])) {
317 DI::mstdnError()->RecordNotFound();
320 if (!Item::markForDeletionById($item['id'])) {
321 DI::mstdnError()->RecordNotFound();
324 System::jsonExit([]);
328 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
330 protected function rawContent(array $request = [])
332 $uid = self::getCurrentUserID();
334 if (empty($this->parameters['id'])) {
335 DI::mstdnError()->UnprocessableEntity();
338 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
341 private function getApp(): string
343 if (!empty(self::getCurrentApplication()['name'])) {
344 return self::getCurrentApplication()['name'];
351 * Store provided media ids in the item array and adjust permissions
353 * @param array $media_ids
357 private function storeMediaIds(array $media_ids, array $item): array
359 $item['object-type'] = Activity\ObjectType::IMAGE;
360 $item['post-type'] = Item::PT_IMAGE;
361 $item['attachments'] = [];
363 foreach ($media_ids as $id) {
364 $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
365 WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
366 ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
372 Photo::setPermissionForRessource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
374 $ressources[] = $media[0]['resource-id'];
375 $phototypes = Images::supportedTypes();
376 $ext = $phototypes[$media[0]['type']];
378 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
379 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
380 'size' => $media[0]['datasize'],
381 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
382 'description' => $media[0]['desc'] ?? '',
383 'width' => $media[0]['width'],
384 'height' => $media[0]['height']];
386 if (count($media) > 1) {
387 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
388 $attachment['preview-width'] = $media[1]['width'];
389 $attachment['preview-height'] = $media[1]['height'];
391 $item['attachments'][] = $attachment;