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