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\Circle;
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 $this->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 . '.');
80 $item['uid'] = $post['uid'];
81 $item['body'] = $this->formatStatus($request['status'], $uid);
82 $item['network'] = $post['network'];
83 $item['gravity'] = $post['gravity'];
84 $item['verb'] = $post['verb'];
85 $item['app'] = $this->getApp();
87 if (!empty($request['language'])) {
88 $item['language'] = json_encode([$request['language'] => 1]);
91 if ($post['gravity'] == Item::GRAVITY_PARENT) {
92 $item['title'] = $request['friendica']['title'] ?? '';
95 $spoiler_text = $request['spoiler_text'];
97 if (!empty($spoiler_text)) {
98 if (!isset($request['friendica']['title']) && $post['gravity'] == Item::GRAVITY_PARENT && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
99 $item['title'] = $spoiler_text;
101 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $spoiler_text . "[/abstract]\n" . $item['body'];
102 $item['content-warning'] = BBCode::toPlaintext($spoiler_text);
106 $item = DI::contentItem()->expandTags($item);
109 The provided ids in the request value consists of these two sources:
110 - The id in the "photo" table for newly uploaded media
111 - The id in the "post-media" table for already attached media
113 Because of this we have to add all media that isn't already attached.
114 Also we have to delete all media that isn't provided anymore.
116 There is a possible situation where the newly uploaded media
117 could have the same id as an existing, but deleted media.
119 We can't do anything about this, but the probability for this is extremely low.
122 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
124 foreach ($request['media_ids'] as $media) {
125 if (!in_array($media, $existing_media)) {
126 $media_ids[] = $media;
130 foreach ($existing_media as $media) {
131 if (!in_array($media, $request['media_ids'])) {
132 Post\Media::deleteById($media);
136 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
138 foreach ($item['attachments'] as $attachment) {
139 $attachment['uri-id'] = $post['uri-id'];
140 Post\Media::insert($attachment);
142 unset($item['attachments']);
144 if (!Item::isValid($item)) {
145 throw new \Exception('Missing parameters in definition');
148 // Link Preview Attachment Processing
149 Post\Media::deleteByURIId($post['uri-id'], [Post\Media::HTML]);
151 Item::update($item, ['id' => $post['id']]);
153 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
154 Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
156 // Store tags from the body if this hadn't been handled previously in the protocol classes
158 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
160 Item::updateDisplayCache($post['uri-id']);
162 $this->jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
165 protected function post(array $request = [])
167 $this->checkAllowedScope(self::SCOPE_WRITE);
168 $uid = self::getCurrentUserID();
170 $request = $this->getRequest([
171 'status' => '', // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
172 'media_ids' => [], // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
173 'poll' => [], // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
174 'in_reply_to_id' => 0, // ID of the status being replied to, if status is a reply
175 'quote_id' => 0, // ID of the message to quote
176 'sensitive' => false, // Mark status and attached media as sensitive?
177 'spoiler_text' => '', // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
178 'visibility' => '', // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
179 '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.
180 'language' => '', // ISO 639 language code for this status.
181 'friendica' => [], // Friendica extensions to the standard Mastodon API spec
184 $owner = User::getOwnerDataById($uid);
187 $item['network'] = Protocol::DFRN;
189 $item['verb'] = Activity::POST;
190 $item['contact-id'] = $owner['id'];
191 $item['author-id'] = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
193 $item['body'] = $this->formatStatus($request['status'], $uid);
194 $item['app'] = $this->getApp();
196 switch ($request['visibility']) {
198 $item['allow_cid'] = '';
199 $item['allow_gid'] = '';
200 $item['deny_cid'] = '';
201 $item['deny_gid'] = '';
202 $item['private'] = Item::PUBLIC;
205 $item['allow_cid'] = '';
206 $item['allow_gid'] = '';
207 $item['deny_cid'] = '';
208 $item['deny_gid'] = '';
209 $item['private'] = Item::UNLISTED;
212 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
213 $item['allow_cid'] = $owner['allow_cid'];
214 $item['allow_gid'] = $owner['allow_gid'];
215 $item['deny_cid'] = $owner['deny_cid'];
216 $item['deny_gid'] = $owner['deny_gid'];
218 $item['allow_cid'] = '';
219 $item['allow_gid'] = '<' . Circle::FOLLOWERS . '>';
220 $item['deny_cid'] = '';
221 $item['deny_gid'] = '';
223 $item['private'] = Item::PRIVATE;
226 $item['private'] = Item::PRIVATE;
227 // The permissions are assigned in "expandTags"
230 if (is_numeric($request['visibility']) && Circle::exists($request['visibility'], $uid)) {
231 $item['allow_cid'] = '';
232 $item['allow_gid'] = '<' . $request['visibility'] . '>';
233 $item['deny_cid'] = '';
234 $item['deny_gid'] = '';
236 $item['allow_cid'] = $owner['allow_cid'];
237 $item['allow_gid'] = $owner['allow_gid'];
238 $item['deny_cid'] = $owner['deny_cid'];
239 $item['deny_gid'] = $owner['deny_gid'];
242 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
243 $item['private'] = Item::PRIVATE;
244 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
245 $item['private'] = Item::UNLISTED;
247 $item['private'] = Item::PUBLIC;
252 if (!empty($request['language'])) {
253 $item['language'] = json_encode([$request['language'] => 1]);
256 if ($request['in_reply_to_id']) {
257 $parent = Post::selectOriginal(['uri'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
258 if (empty($parent)) {
259 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['in_reply_to_id'] . ' not found for user ' . $uid . '.');
262 $item['thr-parent'] = $parent['uri'];
263 $item['gravity'] = Item::GRAVITY_COMMENT;
264 $item['object-type'] = Activity\ObjectType::COMMENT;
266 $this->checkThrottleLimit();
268 $item['gravity'] = Item::GRAVITY_PARENT;
269 $item['object-type'] = Activity\ObjectType::NOTE;
272 if ($request['quote_id']) {
273 if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
274 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
276 $item['quote-uri-id'] = $request['quote_id'];
279 $item['title'] = $request['friendica']['title'] ?? '';
281 if (!empty($request['spoiler_text'])) {
282 if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
283 $item['title'] = $request['spoiler_text'];
285 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
289 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
291 if (!empty($request['media_ids'])) {
292 $item = $this->storeMediaIds($request['media_ids'], $item);
295 if (!empty($request['scheduled_at'])) {
296 $item['guid'] = Item::guid($item, true);
297 $item['uri'] = Item::newURI($item['guid']);
298 $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
300 DI::mstdnError()->InternalError();
302 $this->jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
305 $id = Item::insert($item, true);
307 $item = Post::selectFirst(['uri-id'], ['id' => $id]);
308 if (!empty($item['uri-id'])) {
309 $this->jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
313 DI::mstdnError()->InternalError();
316 protected function delete(array $request = [])
318 $this->checkAllowedScope(self::SCOPE_READ);
319 $uid = self::getCurrentUserID();
321 if (empty($this->parameters['id'])) {
322 DI::mstdnError()->UnprocessableEntity();
325 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
326 if (empty($item['id'])) {
327 $this->logErrorAndJsonExit(404, $this->errorFactory->RecordNotFound());
330 if (!Item::markForDeletionById($item['id'])) {
331 $this->logErrorAndJsonExit(404, $this->errorFactory->RecordNotFound());
338 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
340 protected function rawContent(array $request = [])
342 $uid = self::getCurrentUserID();
344 if (empty($this->parameters['id'])) {
345 DI::mstdnError()->UnprocessableEntity();
348 $this->jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
351 private function getApp(): string
353 if (!empty(self::getCurrentApplication()['name'])) {
354 return self::getCurrentApplication()['name'];
361 * Store provided media ids in the item array and adjust permissions
363 * @param array $media_ids
367 private function storeMediaIds(array $media_ids, array $item): array
369 $item['object-type'] = Activity\ObjectType::IMAGE;
370 $item['post-type'] = Item::PT_IMAGE;
371 $item['attachments'] = [];
373 foreach ($media_ids as $id) {
374 $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
375 WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
376 ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
382 Photo::setPermissionForResource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
384 $phototypes = Images::supportedTypes();
385 $ext = $phototypes[$media[0]['type']];
387 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
388 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
389 'size' => $media[0]['datasize'],
390 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
391 'description' => $media[0]['desc'] ?? '',
392 'width' => $media[0]['width'],
393 'height' => $media[0]['height']];
395 if (count($media) > 1) {
396 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
397 $attachment['preview-width'] = $media[1]['width'];
398 $attachment['preview-height'] = $media[1]['height'];
400 $item['attachments'][] = $attachment;
406 * Format the status via Markdown and a link description if enabled for this user
408 * @param string $status
409 * @param integer $uid
412 private function formatStatus(string $status, int $uid): string
414 // The input is defined as text. So we can use Markdown for some enhancements
415 $status = Markdown::toBBCode($status);
417 if (!DI::pConfig()->get($uid, 'system', 'api_auto_attach', false)) {
421 $status = BBCode::expandVideoLinks($status);
422 if (preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $status, $matches)) {
423 $status = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $status);