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