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