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