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