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