]> git.mxchange.org Git - friendica.git/blob - src/Module/Api/Mastodon/Statuses.php
Fix being able to remove all images from post through Mastodon 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                 /*
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
117
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.
120
121                 There is a possible situation where the newly uploaded media
122                 could have the same id as an existing, but deleted media.
123
124                 We can't do anything about this, but the probability for this is extremely low.
125                 */
126                 $media_ids      = [];
127                 $existing_media = array_column(Post\Media::getByURIId($post['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]), 'id');
128
129                 foreach ($request['media_ids'] as $media) {
130                         if (!in_array($media, $existing_media)) {
131                                 $media_ids[] = $media;
132                         }
133                 }
134
135                 foreach ($existing_media as $media) {
136                         if (!in_array($media, $request['media_ids'])) {
137                                 Post\Media::deleteById($media);
138                         }
139                 }
140
141                 $item = $this->storeMediaIds($media_ids, array_merge($post, $item));
142
143                 foreach ($item['attachments'] as $attachment) {
144                         $attachment['uri-id'] = $post['uri-id'];
145                         Post\Media::insert($attachment);
146                 }
147                 unset($item['attachments']);
148
149                 if (!Item::isValid($item)) {
150                         throw new \Exception('Missing parameters in definition');
151                 }
152
153                 Item::update($item, ['id' => $post['id']]);
154
155                 foreach (Tag::getByURIId($post['uri-id']) as $tagToRemove) {
156                         Tag::remove($post['uri-id'], $tagToRemove['type'], $tagToRemove['name'], $tagToRemove['url']);
157                 }
158                 // Store tags from the body if this hadn't been handled previously in the protocol classes
159
160                 Tag::storeFromBody($post['uri-id'], Item::setHashtags($item['body']));
161
162                 Item::updateDisplayCache($post['uri-id']);
163
164                 System::jsonExit(DI::mstdnStatus()->createFromUriId($post['uri-id'], $uid, self::appSupportsQuotes()));
165         }
166
167         protected function post(array $request = [])
168         {
169                 self::checkAllowedScope(self::SCOPE_WRITE);
170                 $uid = self::getCurrentUserID();
171
172                 $request = $this->getRequest([
173                         'status'         => '',    // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
174                         'media_ids'      => [],    // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
175                         'poll'           => [],    // Poll data. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
176                         'in_reply_to_id' => 0,     // ID of the status being replied to, if status is a reply
177                         'quote_id'       => 0,     // ID of the message to quote
178                         'sensitive'      => false, // Mark status and attached media as sensitive?
179                         'spoiler_text'   => '',    // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
180                         'visibility'     => '',    // Visibility of the posted status. One of: "public", "unlisted", "private" or "direct".
181                         '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.
182                         'language'       => '',    // ISO 639 language code for this status.
183                         'friendica'      => [],    // Friendica extensions to the standard Mastodon API spec
184                 ], $request);
185
186                 $owner = User::getOwnerDataById($uid);
187
188                 // The imput is defined as text. So we can use Markdown for some enhancements
189                 $body = Markdown::toBBCode($request['status']);
190
191                 if (DI::pConfig()->get($uid, 'system', 'api_auto_attach', false) && preg_match("/\[url=[^\[\]]*\](.*)\[\/url\]\z/ism", $body, $matches)) {
192                         $body = preg_replace("/\[url=[^\[\]]*\].*\[\/url\]\z/ism", PageInfo::getFooterFromUrl($matches[1]), $body);
193                 }
194
195                 $item               = [];
196                 $item['network']    = Protocol::DFRN;
197                 $item['uid']        = $uid;
198                 $item['verb']       = Activity::POST;
199                 $item['contact-id'] = $owner['id'];
200                 $item['author-id']  = $item['owner-id'] = Contact::getPublicIdByUserId($uid);
201                 $item['title']      = '';
202                 $item['body']       = $body;
203                 $item['app']        = $this->getApp();
204
205                 switch ($request['visibility']) {
206                         case 'public':
207                                 $item['allow_cid'] = '';
208                                 $item['allow_gid'] = '';
209                                 $item['deny_cid']  = '';
210                                 $item['deny_gid']  = '';
211                                 $item['private']   = Item::PUBLIC;
212                                 break;
213                         case 'unlisted':
214                                 $item['allow_cid'] = '';
215                                 $item['allow_gid'] = '';
216                                 $item['deny_cid']  = '';
217                                 $item['deny_gid']  = '';
218                                 $item['private']   = Item::UNLISTED;
219                                 break;
220                         case 'private':
221                                 if (!empty($owner['allow_cid'] . $owner['allow_gid'] . $owner['deny_cid'] . $owner['deny_gid'])) {
222                                         $item['allow_cid'] = $owner['allow_cid'];
223                                         $item['allow_gid'] = $owner['allow_gid'];
224                                         $item['deny_cid']  = $owner['deny_cid'];
225                                         $item['deny_gid']  = $owner['deny_gid'];
226                                 } else {
227                                         $item['allow_cid'] = '';
228                                         $item['allow_gid'] = '<' . Group::FOLLOWERS . '>';
229                                         $item['deny_cid']  = '';
230                                         $item['deny_gid']  = '';
231                                 }
232                                 $item['private'] = Item::PRIVATE;
233                                 break;
234                         case 'direct':
235                                 $item['private'] = Item::PRIVATE;
236                                 // The permissions are assigned in "expandTags"
237                                 break;
238                         default:
239                                 if (is_numeric($request['visibility']) && Group::exists($request['visibility'], $uid)) {
240                                         $item['allow_cid'] = '';
241                                         $item['allow_gid'] = '<' . $request['visibility'] . '>';
242                                         $item['deny_cid']  = '';
243                                         $item['deny_gid']  = '';
244                                 } else {
245                                         $item['allow_cid'] = $owner['allow_cid'];
246                                         $item['allow_gid'] = $owner['allow_gid'];
247                                         $item['deny_cid']  = $owner['deny_cid'];
248                                         $item['deny_gid']  = $owner['deny_gid'];
249                                 }
250
251                                 if (!empty($item['allow_cid'] . $item['allow_gid'] . $item['deny_cid'] . $item['deny_gid'])) {
252                                         $item['private'] = Item::PRIVATE;
253                                 } elseif (DI::pConfig()->get($uid, 'system', 'unlisted')) {
254                                         $item['private'] = Item::UNLISTED;
255                                 } else {
256                                         $item['private'] = Item::PUBLIC;
257                                 }
258                                 break;
259                 }
260
261                 if (!empty($request['language'])) {
262                         $item['language'] = json_encode([$request['language'] => 1]);
263                 }
264
265                 if ($request['in_reply_to_id']) {
266                         $parent = Post::selectFirst(['uri', 'private'], ['uri-id' => $request['in_reply_to_id'], 'uid' => [0, $uid]]);
267
268                         $item['thr-parent']  = $parent['uri'];
269                         $item['gravity']     = Item::GRAVITY_COMMENT;
270                         $item['object-type'] = Activity\ObjectType::COMMENT;
271
272                         if (in_array($parent['private'], [Item::UNLISTED, Item::PUBLIC]) && ($item['private'] == Item::PRIVATE)) {
273                                 throw new HTTPException\NotImplementedException('Private replies for public posts are not implemented.');
274                         }
275                 } else {
276                         self::checkThrottleLimit();
277
278                         $item['gravity']     = Item::GRAVITY_PARENT;
279                         $item['object-type'] = Activity\ObjectType::NOTE;
280                 }
281
282                 if ($request['quote_id']) {
283                         if (!Post::exists(['uri-id' => $request['quote_id'], 'uid' => [0, $uid]])) {
284                                 throw new HTTPException\NotFoundException('Item with URI ID ' . $request['quote_id'] . ' not found for user ' . $uid . '.');
285                         }
286                         $item['quote-uri-id'] = $request['quote_id'];
287                 }
288
289                 $item['title'] = $request['friendica']['title'] ?? '';
290
291                 if (!empty($request['spoiler_text'])) {
292                         if (!isset($request['friendica']['title']) && !$request['in_reply_to_id'] && DI::pConfig()->get($uid, 'system', 'api_spoiler_title', true)) {
293                                 $item['title'] = $request['spoiler_text'];
294                         } else {
295                                 $item['body'] = '[abstract=' . Protocol::ACTIVITYPUB . ']' . $request['spoiler_text'] . "[/abstract]\n" . $item['body'];
296                         }
297                 }
298
299                 $item = DI::contentItem()->expandTags($item, $request['visibility'] == 'direct');
300
301                 if (!empty($request['media_ids'])) {
302                         $item = $this->storeMediaIds($request['media_ids'], $item);
303                 }
304
305                 if (!empty($request['scheduled_at'])) {
306                         $item['guid'] = Item::guid($item, true);
307                         $item['uri'] = Item::newURI($item['guid']);
308                         $id = Post\Delayed::add($item['uri'], $item, Worker::PRIORITY_HIGH, Post\Delayed::PREPARED, DateTimeFormat::utc($request['scheduled_at']));
309                         if (empty($id)) {
310                                 DI::mstdnError()->InternalError();
311                         }
312                         System::jsonExit(DI::mstdnScheduledStatus()->createFromDelayedPostId($id, $uid)->toArray());
313                 }
314
315                 $id = Item::insert($item, true);
316                 if (!empty($id)) {
317                         $item = Post::selectFirst(['uri-id'], ['id' => $id]);
318                         if (!empty($item['uri-id'])) {
319                                 System::jsonExit(DI::mstdnStatus()->createFromUriId($item['uri-id'], $uid, self::appSupportsQuotes()));
320                         }
321                 }
322
323                 DI::mstdnError()->InternalError();
324         }
325
326         protected function delete(array $request = [])
327         {
328                 self::checkAllowedScope(self::SCOPE_READ);
329                 $uid = self::getCurrentUserID();
330
331                 if (empty($this->parameters['id'])) {
332                         DI::mstdnError()->UnprocessableEntity();
333                 }
334
335                 $item = Post::selectFirstForUser($uid, ['id'], ['uri-id' => $this->parameters['id'], 'uid' => $uid]);
336                 if (empty($item['id'])) {
337                         DI::mstdnError()->RecordNotFound();
338                 }
339
340                 if (!Item::markForDeletionById($item['id'])) {
341                         DI::mstdnError()->RecordNotFound();
342                 }
343
344                 System::jsonExit([]);
345         }
346
347         /**
348          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
349          */
350         protected function rawContent(array $request = [])
351         {
352                 $uid = self::getCurrentUserID();
353
354                 if (empty($this->parameters['id'])) {
355                         DI::mstdnError()->UnprocessableEntity();
356                 }
357
358                 System::jsonExit(DI::mstdnStatus()->createFromUriId($this->parameters['id'], $uid, self::appSupportsQuotes(), false));
359         }
360
361         private function getApp(): string
362         {
363                 if (!empty(self::getCurrentApplication()['name'])) {
364                         return self::getCurrentApplication()['name'];
365                 } else {
366                         return 'API';
367                 }
368         }
369
370         /**
371          * Store provided media ids in the item array and adjust permissions
372          *
373          * @param array $media_ids
374          * @param array $item
375          * @return array
376          */
377         private function storeMediaIds(array $media_ids, array $item): array
378         {
379                 $item['object-type'] = Activity\ObjectType::IMAGE;
380                 $item['post-type']   = Item::PT_IMAGE;
381                 $item['attachments'] = [];
382
383                 foreach ($media_ids as $id) {
384                         $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
385                                         WHERE `resource-id` IN (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
386                                         ORDER BY `photo`.`width` DESC LIMIT 2", $id, $item['uid']));
387
388                         if (empty($media)) {
389                                 continue;
390                         }
391
392                         Photo::setPermissionForRessource($media[0]['resource-id'], $item['uid'], $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
393
394                         $ressources[] = $media[0]['resource-id'];
395                         $phototypes = Images::supportedTypes();
396                         $ext = $phototypes[$media[0]['type']];
397
398                         $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
399                                 'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
400                                 'size' => $media[0]['datasize'],
401                                 'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
402                                 'description' => $media[0]['desc'] ?? '',
403                                 'width' => $media[0]['width'],
404                                 'height' => $media[0]['height']];
405
406                         if (count($media) > 1) {
407                                 $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
408                                 $attachment['preview-width'] = $media[1]['width'];
409                                 $attachment['preview-height'] = $media[1]['height'];
410                         }
411                         $item['attachments'][] = $attachment;
412                 }
413                 return $item;
414         }
415 }