]> git.mxchange.org Git - friendica.git/blob - src/Module/Api/Mastodon/Statuses.php
Fix tag and mention updates in Mastodon status edit endpoint
[friendica.git] / src / Module / Api / Mastodon / Statuses.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2023, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
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.
11  *
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.
16  *
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/>.
19  *
20  */
21
22 namespace Friendica\Module\Api\Mastodon;
23
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;
31 use Friendica\DI;
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;
44
45 /**
46  * @see https://docs.joinmastodon.org/methods/statuses/
47  */
48 class Statuses extends BaseApi
49 {
50         public function put(array $request = [])
51         {
52                 self::checkAllowedScope(self::SCOPE_WRITE);
53                 $uid = self::getCurrentUserID();
54
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.
61                         'friendica'      => [],
62                 ], $request);
63
64                 $owner = User::getOwnerDataById($uid);
65
66                 $condition = [
67                         'uid'        => $uid,
68                         'uri-id'     => $this->parameters['id'],
69                         'contact-id' => $owner['id'],
70                         'author-id'  => Contact::getPublicIdByUserId($uid),
71                         'origin'     => true,
72                 ];
73
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 . '.');
77                 }
78
79                 // The imput is defined as text. So we can use Markdown for some enhancements
80                 $body = Markdown::toBBCode($request['status']);
81
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);
84                 }
85
86                 $item['title']      = '';
87                 $item['uid']        = $post['uid'];
88                 $item['body']       = $body;
89                 $item['network']    = $post['network'];
90                 $item['app']        = $this->getApp();
91
92                 if (!empty($request['language'])) {
93                         $item['language'] = json_encode([$request['language'] => 1]);
94                 }
95
96                 if ($post['gravity'] == Item::GRAVITY_PARENT) {
97                         $item['title'] = $request['friendica']['title'] ?? '';
98                 }
99
100                 $spoiler_text = $request['spoiler_text'];
101
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;
105                         } else {
106                                 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $spoiler_text . "[/abstract]\n" . $item['body'];
107                                 $item['content-warning'] = BBCode::toPlaintext($spoiler_text);
108                         }
109                 }
110
111                 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
112
113                 if (!empty($request['media_ids'])) {
114                         /*
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
118
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.
121                         
122                         There is a possible situation where the newly uploaded media
123                         could have the same id as an existing, but deleted media.
124
125                         We can't do anything about this, but the probability for this is extremely low.
126                         */
127                         $media_ids      = [];
128                         $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
129
130                         foreach ($request['media_ids'] as $media) {
131                                 if (!in_array($media, $existing_media)) {
132                                         $media_ids[] = $media;
133                                 }
134                         }
135
136                         foreach ($existing_media as $media) {
137                                 if (!in_array($media, $request['media_ids'])) {
138                                         Post\Media::deleteById($media);
139                                 }
140                         }
141
142                         $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
143
144                         foreach ($item['attachments'] as $attachment) {
145                                 $attachment['uri-id'] = $post['uri-id'];
146                                 Post\Media::insert($attachment);
147                         }
148                         unset($item['attachments']);
149                 }
150                 if (!Item::isValid($item)) {
151                         throw new \Exception('Missing parameters in definition');
152                 }
153
154                 Item::update($item, ['id' => $post['id']]);
155
156                 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
157                         Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
158                 }
159                 // Store tags from the body if this hadn't been handled previously in the protocol classes
160
161                 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
162
163                 Item::updateDisplayCache($post['uri-id']);
164
165                 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
166         }
167
168         protected function post(array $request = [])
169         {
170                 self::checkAllowedScope(self::SCOPE_WRITE);
171                 $uid = self::getCurrentUserID();
172
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
185                 ], $request);
186
187                 $owner = User::getOwnerDataById($uid);
188
189                 // The imput is defined as text. So we can use Markdown for some enhancements
190                 $body = Markdown::toBBCode($request['status']);
191
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);
194                 }
195
196                 $item               = [];
197                 $item['network']    = Protocol::DFRN;
198                 $item['uid']        = $uid;
199                 $item['verb']       = Activity::POST;
200                 $item['contact-id'] = $owner['id'];
201                 $item['author-id']  = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
202                 $item['title']      = '';
203                 $item['body']       = $body;
204                 $item['app']        = $this->getApp();
205
206                 switch ($request['visibility']) {
207                         case 'public':
208                                 $item['allow_cid'] = '';
209                                 $item['allow_gid'] = '';
210                                 $item['deny_cid']  = '';
211                                 $item['deny_gid']  = '';
212                                 $item['private']   = Item::PUBLIC;
213                                 break;
214                         case 'unlisted':
215                                 $item['allow_cid'] = '';
216                                 $item['allow_gid'] = '';
217                                 $item['deny_cid']  = '';
218                                 $item['deny_gid']  = '';
219                                 $item['private']   = Item::UNLISTED;
220                                 break;
221                         case 'private':
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'];
227                                 } else {
228                                         $item['allow_cid'] = '';
229                                         $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
230                                         $item['deny_cid']  = '';
231                                         $item['deny_gid']  = '';
232                                 }
233                                 $item['private'] = Item::PRIVATE;
234                                 break;
235                         case 'direct':
236                                 $item['private'] = Item::PRIVATE;
237                                 // The permissions are assigned in "expandTags"
238                                 break;
239                         default:
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']  = '';
245                                 } else {
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'];
250                                 }
251
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;
256                                 } else {
257                                         $item['private'] = Item::PUBLIC;
258                                 }
259                                 break;
260                 }
261
262                 if (!empty($request['language'])) {
263                         $item['language'] = json_encode([$request['language'] => 1]);
264                 }
265
266                 if ($request['in_reply_to_id']) {
267                         $parent = Post::selectFirst(['uri', 'private'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
268
269                         $item['thr-parent']  = $parent['uri'];
270                         $item['gravity']     = Item::GRAVITY_COMMENT;
271                         $item['object-type'] = Activity\ObjectType::COMMENT;
272
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.');
275                         }
276                 } else {
277                         self::checkThrottleLimit();
278
279                         $item['gravity']     = Item::GRAVITY_PARENT;
280                         $item['object-type'] = Activity\ObjectType::NOTE;
281                 }
282
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 . '.');
286                         }
287                         $item['quote-uri-id'] = $request['quote_id'];
288                 }
289
290                 $item['title'] = $request['friendica']['title'] ?? '';
291
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'];
295                         } else {
296                                 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
297                         }
298                 }
299
300                 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
301
302                 if (!empty($request['media_ids'])) {
303                         $item = $this->storeMediaIds($request['media_ids'], $item);
304                 }
305
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']));
310                         if (empty($id)) {
311                                 DI::mstdnError()->InternalError();
312                         }
313                         System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
314                 }
315
316                 $id = Item::insert($item, true);
317                 if (!empty($id)) {
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()));
321                         }
322                 }
323
324                 DI::mstdnError()->InternalError();
325         }
326
327         protected function delete(array $request = [])
328         {
329                 self::checkAllowedScope(self::SCOPE_READ);
330                 $uid = self::getCurrentUserID();
331
332                 if (empty($this->parameters['id'])) {
333                         DI::mstdnError()->UnprocessableEntity();
334                 }
335
336                 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
337                 if (empty($item['id'])) {
338                         DI::mstdnError()->RecordNotFound();
339                 }
340
341                 if (!Item::markForDeletionById($item['id'])) {
342                         DI::mstdnError()->RecordNotFound();
343                 }
344
345                 System::jsonExit([]);
346         }
347
348         /**
349          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
350          */
351         protected function rawContent(array $request = [])
352         {
353                 $uid = self::getCurrentUserID();
354
355                 if (empty($this->parameters['id'])) {
356                         DI::mstdnError()->UnprocessableEntity();
357                 }
358
359                 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
360         }
361
362         private function getApp(): string
363         {
364                 if (!empty(self::getCurrentApplication()['name'])) {
365                         return self::getCurrentApplication()['name'];
366                 } else {
367                         return 'API';
368                 }
369         }
370
371         /**
372          * Store provided media ids in the item array and adjust permissions
373          *
374          * @param array $media_ids
375          * @param array $item
376          * @return array
377          */
378         private function storeMediaIds(array $media_ids, array $item): array
379         {
380                 $item['object-type'] = Activity\ObjectType::IMAGE;
381                 $item['post-type']   = Item::PT_IMAGE;
382                 $item['attachments'] = [];
383
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']));
388
389                         if (empty($media)) {
390                                 continue;
391                         }
392
393                         Photo::setPermissionForRessource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
394
395                         $ressources[] = $media[0]['resource-id'];
396                         $phototypes = Images::supportedTypes();
397                         $ext = $phototypes[$media[0]['type']];
398
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']];
406
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'];
411                         }
412                         $item['attachments'][] = $attachment;
413                 }
414                 return $item;
415         }
416 }