]> git.mxchange.org Git - friendica.git/blob - include/api.php
e5d963032bfec3f05fa9a5677a3c6eb096386922
[friendica.git] / include / api.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2021, 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  * Friendica implementation of statusnet/twitter API
21  *
22  * @file include/api.php
23  * @todo Automatically detect if incoming data is HTML or BBCode
24  */
25
26 use Friendica\App;
27 use Friendica\Content\ContactSelector;
28 use Friendica\Content\Text\BBCode;
29 use Friendica\Content\Text\HTML;
30 use Friendica\Core\Logger;
31 use Friendica\Core\Protocol;
32 use Friendica\Core\System;
33 use Friendica\Database\DBA;
34 use Friendica\DI;
35 use Friendica\Model\Contact;
36 use Friendica\Model\Group;
37 use Friendica\Model\Item;
38 use Friendica\Model\Mail;
39 use Friendica\Model\Notification;
40 use Friendica\Model\Photo;
41 use Friendica\Model\Post;
42 use Friendica\Model\Profile;
43 use Friendica\Model\User;
44 use Friendica\Model\Verb;
45 use Friendica\Module\BaseApi;
46 use Friendica\Network\HTTPException;
47 use Friendica\Network\HTTPException\BadRequestException;
48 use Friendica\Network\HTTPException\ForbiddenException;
49 use Friendica\Network\HTTPException\InternalServerErrorException;
50 use Friendica\Network\HTTPException\MethodNotAllowedException;
51 use Friendica\Network\HTTPException\NotFoundException;
52 use Friendica\Network\HTTPException\TooManyRequestsException;
53 use Friendica\Network\HTTPException\UnauthorizedException;
54 use Friendica\Object\Image;
55 use Friendica\Protocol\Activity;
56 use Friendica\Security\BasicAuth;
57 use Friendica\Util\DateTimeFormat;
58 use Friendica\Util\Images;
59 use Friendica\Util\Network;
60 use Friendica\Util\Strings;
61
62 require_once __DIR__ . '/../mod/item.php';
63 require_once __DIR__ . '/../mod/wall_upload.php';
64
65 define('API_METHOD_ANY', '*');
66 define('API_METHOD_GET', 'GET');
67 define('API_METHOD_POST', 'POST,PUT');
68 define('API_METHOD_DELETE', 'POST,DELETE');
69
70 define('API_LOG_PREFIX', 'API {action} - ');
71
72 $API = [];
73
74 /**
75  * Get source name from API client
76  *
77  * Clients can send 'source' parameter to be show in post metadata
78  * as "sent via <source>".
79  * Some clients doesn't send a source param, we support ones we know
80  * (only Twidere, atm)
81  *
82  * @return string
83  *        Client source name, default to "api" if unset/unknown
84  * @throws Exception
85  */
86 function api_source()
87 {
88         if (requestdata('source')) {
89                 return requestdata('source');
90         }
91
92         // Support for known clients that doesn't send a source name
93         if (!empty($_SERVER['HTTP_USER_AGENT'])) {
94                 if(strpos($_SERVER['HTTP_USER_AGENT'], "Twidere") !== false) {
95                         return "Twidere";
96                 }
97
98                 Logger::info(API_LOG_PREFIX . 'Unrecognized user-agent', ['module' => 'api', 'action' => 'source', 'http_user_agent' => $_SERVER['HTTP_USER_AGENT']]);
99         } else {
100                 Logger::info(API_LOG_PREFIX . 'Empty user-agent', ['module' => 'api', 'action' => 'source']);
101         }
102
103         return "api";
104 }
105
106 /**
107  * Register a function to be the endpoint for defined API path.
108  *
109  * @param string $path   API URL path, relative to DI::baseUrl()
110  * @param string $func   Function name to call on path request
111  * @param bool   $auth   API need logged user
112  * @param string $method HTTP method reqiured to call this endpoint.
113  *                       One of API_METHOD_ANY, API_METHOD_GET, API_METHOD_POST.
114  *                       Default to API_METHOD_ANY
115  */
116 function api_register_func($path, $func, $auth = false, $method = API_METHOD_ANY)
117 {
118         global $API;
119
120         $API[$path] = [
121                 'func'   => $func,
122                 'auth'   => $auth,
123                 'method' => $method,
124         ];
125
126         // Workaround for hotot
127         $path = str_replace("api/", "api/1.1/", $path);
128
129         $API[$path] = [
130                 'func'   => $func,
131                 'auth'   => $auth,
132                 'method' => $method,
133         ];
134 }
135
136 /**
137  * Main API entry point
138  *
139  * Authenticate user, call registered API function, set HTTP headers
140  *
141  * @param App $a App
142  * @param App\Arguments $args The app arguments (optional, will retrieved by the DI-Container in case of missing)
143  * @return string|array API call result
144  * @throws Exception
145  */
146 function api_call(App $a, App\Arguments $args = null)
147 {
148         global $API;
149
150         if ($args == null) {
151                 $args = DI::args();
152         }
153
154         $type = "json";
155         if (strpos($args->getCommand(), ".xml") > 0) {
156                 $type = "xml";
157         }
158         if (strpos($args->getCommand(), ".json") > 0) {
159                 $type = "json";
160         }
161         if (strpos($args->getCommand(), ".rss") > 0) {
162                 $type = "rss";
163         }
164         if (strpos($args->getCommand(), ".atom") > 0) {
165                 $type = "atom";
166         }
167
168         try {
169                 foreach ($API as $p => $info) {
170                         if (strpos($args->getCommand(), $p) === 0) {
171                                 if (!empty($info['auth']) && BaseApi::getCurrentUserID() === false) {
172                                         BasicAuth::getCurrentUserID(true);
173                                         Logger::info(API_LOG_PREFIX . 'nickname {nickname}', ['module' => 'api', 'action' => 'call', 'nickname' => $a->getLoggedInUserNickname()]);
174                                 }
175
176                                 Logger::debug(API_LOG_PREFIX . 'parameters', ['module' => 'api', 'action' => 'call', 'parameters' => $_REQUEST]);
177
178                                 $stamp =  microtime(true);
179                                 $return = call_user_func($info['func'], $type);
180                                 $duration = floatval(microtime(true) - $stamp);
181
182                                 Logger::info(API_LOG_PREFIX . 'duration {duration}', ['module' => 'api', 'action' => 'call', 'duration' => round($duration, 2)]);
183
184                                 DI::profiler()->saveLog(DI::logger(), API_LOG_PREFIX . 'performance');
185
186                                 if (false === $return) {
187                                         /*
188                                                 * api function returned false withour throw an
189                                                 * exception. This should not happend, throw a 500
190                                                 */
191                                         throw new InternalServerErrorException();
192                                 }
193
194                                 switch ($type) {
195                                         case "xml":
196                                                 header("Content-Type: text/xml");
197                                                 break;
198                                         case "json":
199                                                 header("Content-Type: application/json");
200                                                 if (!empty($return)) {
201                                                         $json = json_encode(end($return));
202                                                         if (!empty($_GET['callback'])) {
203                                                                 $json = $_GET['callback'] . "(" . $json . ")";
204                                                         }
205                                                         $return = $json;
206                                                 }
207                                                 break;
208                                         case "rss":
209                                                 header("Content-Type: application/rss+xml");
210                                                 $return  = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $return;
211                                                 break;
212                                         case "atom":
213                                                 header("Content-Type: application/atom+xml");
214                                                 $return = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $return;
215                                                 break;
216                                 }
217                                 return $return;
218                         }
219                 }
220
221                 Logger::warning(API_LOG_PREFIX . 'not implemented', ['module' => 'api', 'action' => 'call', 'query' => DI::args()->getQueryString()]);
222                 throw new NotFoundException();
223         } catch (HTTPException $e) {
224                 Logger::notice(API_LOG_PREFIX . 'got exception', ['module' => 'api', 'action' => 'call', 'query' => DI::args()->getQueryString(), 'error' => $e]);
225                 DI::apiResponse()->error($e->getCode(), $e->getDescription(), $e->getMessage(), $type);
226         }
227 }
228
229 /**
230  * Set values for RSS template
231  *
232  * @param array $arr       Array to be passed to template
233  * @param array $user_info User info
234  * @return array
235  * @throws BadRequestException
236  * @throws ImagickException
237  * @throws InternalServerErrorException
238  * @throws UnauthorizedException
239  * @todo  find proper type-hints
240  */
241 function api_rss_extra($arr, $user_info)
242 {
243         if (is_null($user_info)) {
244                 $uid = BaseApi::getCurrentUserID();
245                 if (empty($uid)) {
246                         throw new ForbiddenException();
247                 }
248
249                 $user_info = DI::twitterUser()->createFromUserId($uid)->toArray();
250         }
251
252         $arr['$user'] = $user_info;
253         $arr['$rss'] = [
254                 'alternate'    => $user_info['url'],
255                 'self'         => DI::baseUrl() . "/" . DI::args()->getQueryString(),
256                 'base'         => DI::baseUrl(),
257                 'updated'      => DateTimeFormat::utc(null, DateTimeFormat::API),
258                 'atom_updated' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
259                 'language'     => $user_info['lang'],
260                 'logo'         => DI::baseUrl() . "/images/friendica-32.png",
261         ];
262
263         return $arr;
264 }
265
266 /**
267  * TWITTER API
268  */
269
270 /**
271  * Returns an HTTP 200 OK response code and a representation of the requesting user if authentication was successful;
272  * returns a 401 status code and an error message if not.
273  *
274  * @see https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/get-account-verify_credentials
275  *
276  * @param string $type Return type (atom, rss, xml, json)
277  * @return array|string
278  * @throws BadRequestException
279  * @throws ForbiddenException
280  * @throws ImagickException
281  * @throws InternalServerErrorException
282  * @throws UnauthorizedException
283  */
284 function api_account_verify_credentials($type)
285 {
286         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
287
288         unset($_REQUEST['user_id']);
289         unset($_GET['user_id']);
290
291         unset($_REQUEST['screen_name']);
292         unset($_GET['screen_name']);
293
294         $skip_status = $_REQUEST['skip_status'] ?? false;
295
296         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
297
298         // "verified" isn't used here in the standard
299         unset($user_info["verified"]);
300
301         // - Adding last status
302         if (!$skip_status) {
303                 $item = api_get_last_status($user_info['pid'], $user_info['uid']);
304                 if (!empty($item)) {
305                         $user_info['status'] = api_format_item($item, $type);
306                 }
307         }
308
309         // "uid" and "self" are only needed for some internal stuff, so remove it from here
310         unset($user_info["uid"]);
311         unset($user_info["self"]);
312
313         return DI::apiResponse()->formatData("user", $type, ['user' => $user_info]);
314 }
315
316 /// @TODO move to top of file or somewhere better
317 api_register_func('api/account/verify_credentials', 'api_account_verify_credentials', true);
318
319 /**
320  * Get data from $_POST or $_GET
321  *
322  * @param string $k
323  * @return null
324  */
325 function requestdata($k)
326 {
327         if (!empty($_POST[$k])) {
328                 return $_POST[$k];
329         }
330         if (!empty($_GET[$k])) {
331                 return $_GET[$k];
332         }
333         return null;
334 }
335
336 /**
337  * Deprecated function to upload media.
338  *
339  * @param string $type Return type (atom, rss, xml, json)
340  *
341  * @return array|string
342  * @throws BadRequestException
343  * @throws ForbiddenException
344  * @throws ImagickException
345  * @throws InternalServerErrorException
346  * @throws UnauthorizedException
347  */
348 function api_statuses_mediap($type)
349 {
350         $a = DI::app();
351
352         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
353
354         $_REQUEST['profile_uid'] = BaseApi::getCurrentUserID();
355         $_REQUEST['api_source'] = true;
356         $txt = requestdata('status') ?? '';
357
358         if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
359                 $txt = HTML::toBBCodeVideo($txt);
360                 $config = HTMLPurifier_Config::createDefault();
361                 $config->set('Cache.DefinitionImpl', null);
362                 $purifier = new HTMLPurifier($config);
363                 $txt = $purifier->purify($txt);
364         }
365         $txt = HTML::toBBCode($txt);
366
367         $picture = wall_upload_post($a, false);
368
369         // now that we have the img url in bbcode we can add it to the status and insert the wall item.
370         $_REQUEST['body'] = $txt . "\n\n" . '[url=' . $picture["albumpage"] . '][img]' . $picture["preview"] . "[/img][/url]";
371         $item_id = item_post($a);
372
373         // output the post that we just posted.
374         return api_status_show($type, $item_id);
375 }
376
377 /// @TODO move this to top of file or somewhere better!
378 api_register_func('api/statuses/mediap', 'api_statuses_mediap', true, API_METHOD_POST);
379
380 /**
381  * Updates the user’s current status.
382  *
383  * @param string $type Return type (atom, rss, xml, json)
384  *
385  * @return array|string
386  * @throws BadRequestException
387  * @throws ForbiddenException
388  * @throws ImagickException
389  * @throws InternalServerErrorException
390  * @throws TooManyRequestsException
391  * @throws UnauthorizedException
392  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
393  */
394 function api_statuses_update($type)
395 {
396         $a = DI::app();
397
398         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
399
400         // convert $_POST array items to the form we use for web posts.
401         if (requestdata('htmlstatus')) {
402                 $txt = requestdata('htmlstatus') ?? '';
403                 if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
404                         $txt = HTML::toBBCodeVideo($txt);
405
406                         $config = HTMLPurifier_Config::createDefault();
407                         $config->set('Cache.DefinitionImpl', null);
408
409                         $purifier = new HTMLPurifier($config);
410                         $txt = $purifier->purify($txt);
411
412                         $_REQUEST['body'] = HTML::toBBCode($txt);
413                 }
414         } else {
415                 $_REQUEST['body'] = requestdata('status');
416         }
417
418         $_REQUEST['title'] = requestdata('title');
419
420         $parent = requestdata('in_reply_to_status_id');
421
422         // Twidere sends "-1" if it is no reply ...
423         if ($parent == -1) {
424                 $parent = "";
425         }
426
427         if (ctype_digit($parent)) {
428                 $_REQUEST['parent'] = $parent;
429         } else {
430                 $_REQUEST['parent_uri'] = $parent;
431         }
432
433         if (requestdata('lat') && requestdata('long')) {
434                 $_REQUEST['coord'] = sprintf("%s %s", requestdata('lat'), requestdata('long'));
435         }
436         $_REQUEST['profile_uid'] = BaseApi::getCurrentUserID();
437
438         if (!$parent) {
439                 // Check for throttling (maximum posts per day, week and month)
440                 $throttle_day = DI::config()->get('system', 'throttle_limit_day');
441                 if ($throttle_day > 0) {
442                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60);
443
444                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, BaseApi::getCurrentUserID(), $datefrom];
445                         $posts_day = Post::count($condition);
446
447                         if ($posts_day > $throttle_day) {
448                                 logger::info('Daily posting limit reached for user '.BaseApi::getCurrentUserID());
449                                 // die(api_error($type, DI::l10n()->t("Daily posting limit of %d posts reached. The post was rejected.", $throttle_day));
450                                 throw new TooManyRequestsException(DI::l10n()->tt("Daily posting limit of %d post reached. The post was rejected.", "Daily posting limit of %d posts reached. The post was rejected.", $throttle_day));
451                         }
452                 }
453
454                 $throttle_week = DI::config()->get('system', 'throttle_limit_week');
455                 if ($throttle_week > 0) {
456                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*7);
457
458                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, BaseApi::getCurrentUserID(), $datefrom];
459                         $posts_week = Post::count($condition);
460
461                         if ($posts_week > $throttle_week) {
462                                 logger::info('Weekly posting limit reached for user '.BaseApi::getCurrentUserID());
463                                 // die(api_error($type, DI::l10n()->t("Weekly posting limit of %d posts reached. The post was rejected.", $throttle_week)));
464                                 throw new TooManyRequestsException(DI::l10n()->tt("Weekly posting limit of %d post reached. The post was rejected.", "Weekly posting limit of %d posts reached. The post was rejected.", $throttle_week));
465                         }
466                 }
467
468                 $throttle_month = DI::config()->get('system', 'throttle_limit_month');
469                 if ($throttle_month > 0) {
470                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*30);
471
472                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, BaseApi::getCurrentUserID(), $datefrom];
473                         $posts_month = Post::count($condition);
474
475                         if ($posts_month > $throttle_month) {
476                                 logger::info('Monthly posting limit reached for user '.BaseApi::getCurrentUserID());
477                                 // die(api_error($type, DI::l10n()->t("Monthly posting limit of %d posts reached. The post was rejected.", $throttle_month));
478                                 throw new TooManyRequestsException(DI::l10n()->t("Monthly posting limit of %d post reached. The post was rejected.", "Monthly posting limit of %d posts reached. The post was rejected.", $throttle_month));
479                         }
480                 }
481         }
482
483         if (requestdata('media_ids')) {
484                 $ids = explode(',', requestdata('media_ids') ?? '');
485         } elseif (!empty($_FILES['media'])) {
486                 // upload the image if we have one
487                 $picture = wall_upload_post($a, false);
488                 if (is_array($picture)) {
489                         $ids[] = $picture['id'];
490                 }
491         }
492
493         $attachments = [];
494         $ressources = [];
495
496         if (!empty($ids)) {
497                 foreach ($ids as $id) {
498                         $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `nickname`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
499                                         INNER JOIN `user` ON `user`.`uid` = `photo`.`uid` WHERE `resource-id` IN
500                                                 (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
501                                         ORDER BY `photo`.`width` DESC LIMIT 2", $id, BaseApi::getCurrentUserID()));
502
503                         if (!empty($media)) {
504                                 $ressources[] = $media[0]['resource-id'];
505                                 $phototypes = Images::supportedTypes();
506                                 $ext = $phototypes[$media[0]['type']];
507
508                                 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
509                                         'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
510                                         'size' => $media[0]['datasize'],
511                                         'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
512                                         'description' => $media[0]['desc'] ?? '',
513                                         'width' => $media[0]['width'],
514                                         'height' => $media[0]['height']];
515
516                                 if (count($media) > 1) {
517                                         $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
518                                         $attachment['preview-width'] = $media[1]['width'];
519                                         $attachment['preview-height'] = $media[1]['height'];
520                                 }
521                                 $attachments[] = $attachment;
522                         }
523                 }
524
525                 // We have to avoid that the post is rejected because of an empty body
526                 if (empty($_REQUEST['body'])) {
527                         $_REQUEST['body'] = '[hr]';
528                 }
529         }
530
531         if (!empty($attachments)) {
532                 $_REQUEST['attachments'] = $attachments;
533         }
534
535         // set this so that the item_post() function is quiet and doesn't redirect or emit json
536
537         $_REQUEST['api_source'] = true;
538
539         if (empty($_REQUEST['source'])) {
540                 $_REQUEST['source'] = api_source();
541         }
542
543         // call out normal post function
544         $item_id = item_post($a);
545
546         if (!empty($ressources) && !empty($item_id)) {
547                 $item = Post::selectFirst(['uri-id', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid'], ['id' => $item_id]);
548                 foreach ($ressources as $ressource) {
549                         Photo::setPermissionForRessource($ressource, BaseApi::getCurrentUserID(), $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
550                 }
551         }
552
553         // output the post that we just posted.
554         return api_status_show($type, $item_id);
555 }
556
557 /// @TODO move to top of file or somewhere better
558 api_register_func('api/statuses/update', 'api_statuses_update', true, API_METHOD_POST);
559 api_register_func('api/statuses/update_with_media', 'api_statuses_update', true, API_METHOD_POST);
560
561 /**
562  * Uploads an image to Friendica.
563  *
564  * @return array
565  * @throws BadRequestException
566  * @throws ForbiddenException
567  * @throws ImagickException
568  * @throws InternalServerErrorException
569  * @throws UnauthorizedException
570  * @see https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload
571  */
572 function api_media_upload()
573 {
574         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
575
576         if (empty($_FILES['media'])) {
577                 // Output error
578                 throw new BadRequestException("No media.");
579         }
580
581         $media = wall_upload_post(DI::app(), false);
582         if (!$media) {
583                 // Output error
584                 throw new InternalServerErrorException();
585         }
586
587         $returndata = [];
588         $returndata["media_id"] = $media["id"];
589         $returndata["media_id_string"] = (string)$media["id"];
590         $returndata["size"] = $media["size"];
591         $returndata["image"] = ["w" => $media["width"],
592                                 "h" => $media["height"],
593                                 "image_type" => $media["type"],
594                                 "friendica_preview_url" => $media["preview"]];
595
596         Logger::info('Media uploaded', ['return' => $returndata]);
597
598         return ["media" => $returndata];
599 }
600
601 /// @TODO move to top of file or somewhere better
602 api_register_func('api/media/upload', 'api_media_upload', true, API_METHOD_POST);
603
604 /**
605  * Updates media meta data (picture descriptions)
606  *
607  * @param string $type Return type (atom, rss, xml, json)
608  *
609  * @return array|string
610  * @throws BadRequestException
611  * @throws ForbiddenException
612  * @throws ImagickException
613  * @throws InternalServerErrorException
614  * @throws TooManyRequestsException
615  * @throws UnauthorizedException
616  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
617  *
618  * @todo Compare the corresponding Twitter function for correct return values
619  */
620 function api_media_metadata_create($type)
621 {
622         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
623
624         $postdata = Network::postdata();
625
626         if (empty($postdata)) {
627                 throw new BadRequestException("No post data");
628         }
629
630         $data = json_decode($postdata, true);
631         if (empty($data)) {
632                 throw new BadRequestException("Invalid post data");
633         }
634
635         if (empty($data['media_id']) || empty($data['alt_text'])) {
636                 throw new BadRequestException("Missing post data values");
637         }
638
639         if (empty($data['alt_text']['text'])) {
640                 throw new BadRequestException("No alt text.");
641         }
642
643         Logger::info('Updating metadata', ['media_id' => $data['media_id']]);
644
645         $condition =  ['id' => $data['media_id'], 'uid' => BaseApi::getCurrentUserID()];
646         $photo = DBA::selectFirst('photo', ['resource-id'], $condition);
647         if (!DBA::isResult($photo)) {
648                 throw new BadRequestException("Metadata not found.");
649         }
650
651         DBA::update('photo', ['desc' => $data['alt_text']['text']], ['resource-id' => $photo['resource-id']]);
652 }
653
654 api_register_func('api/media/metadata/create', 'api_media_metadata_create', true, API_METHOD_POST);
655
656 /**
657  * @param string $type    Return format (atom, rss, xml, json)
658  * @param int    $item_id
659  * @return array|string
660  * @throws Exception
661  */
662 function api_status_show($type, $item_id)
663 {
664         Logger::info(API_LOG_PREFIX . 'Start', ['action' => 'status_show', 'type' => $type, 'item_id' => $item_id]);
665
666         $status_info = [];
667
668         $item = api_get_item(['id' => $item_id]);
669         if (!empty($item)) {
670                 $status_info = api_format_item($item, $type);
671         }
672
673         Logger::info(API_LOG_PREFIX . 'End', ['action' => 'get_status', 'status_info' => $status_info]);
674
675         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
676 }
677
678 /**
679  * Retrieves the last public status of the provided user info
680  *
681  * @param int    $ownerId Public contact Id
682  * @param int    $uid     User Id
683  * @return array
684  * @throws Exception
685  */
686 function api_get_last_status($ownerId, $uid)
687 {
688         $condition = [
689                 'author-id'=> $ownerId,
690                 'uid'      => $uid,
691                 'gravity'  => [GRAVITY_PARENT, GRAVITY_COMMENT],
692                 'private'  => [Item::PUBLIC, Item::UNLISTED]
693         ];
694
695         $item = api_get_item($condition);
696
697         return $item;
698 }
699
700 /**
701  * Retrieves a single item record based on the provided condition and converts it for API use.
702  *
703  * @param array $condition Item table condition array
704  * @return array
705  * @throws Exception
706  */
707 function api_get_item(array $condition)
708 {
709         $item = Post::selectFirst(Item::DISPLAY_FIELDLIST, $condition, ['order' => ['id' => true]]);
710
711         return $item;
712 }
713
714 /**
715  * Returns extended information of a given user, specified by ID or screen name as per the required id parameter.
716  * The author's most recent status will be returned inline.
717  *
718  * @param string $type Return type (atom, rss, xml, json)
719  * @return array|string
720  * @throws BadRequestException
721  * @throws ImagickException
722  * @throws InternalServerErrorException
723  * @throws UnauthorizedException
724  * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-show
725  */
726 function api_users_show($type)
727 {
728         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
729
730         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
731
732         $item = api_get_last_status($user_info['pid'], $user_info['uid']);
733         if (!empty($item)) {
734                 $user_info['status'] = api_format_item($item, $type);
735         }
736
737         // "uid" and "self" are only needed for some internal stuff, so remove it from here
738         unset($user_info['uid']);
739         unset($user_info['self']);
740
741         return DI::apiResponse()->formatData('user', $type, ['user' => $user_info]);
742 }
743
744 /// @TODO move to top of file or somewhere better
745 api_register_func('api/users/show', 'api_users_show');
746 api_register_func('api/externalprofile/show', 'api_users_show');
747
748 /**
749  * Search a public user account.
750  *
751  * @param string $type Return type (atom, rss, xml, json)
752  *
753  * @return array|string
754  * @throws BadRequestException
755  * @throws ImagickException
756  * @throws InternalServerErrorException
757  * @throws UnauthorizedException
758  * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-search
759  */
760 function api_users_search($type)
761 {
762         $userlist = [];
763
764         if (!empty($_GET['q'])) {
765                 $contacts = Contact::selectToArray(
766                         ['id'],
767                         [
768                                 '`uid` = 0 AND (`name` = ? OR `nick` = ? OR `url` = ? OR `addr` = ?)',
769                                 $_GET['q'],
770                                 $_GET['q'],
771                                 $_GET['q'],
772                                 $_GET['q'],
773                         ]
774                 );
775
776                 if (DBA::isResult($contacts)) {
777                         $k = 0;
778                         foreach ($contacts as $contact) {
779                                 $user_info = DI::twitterUser()->createFromContactId($contact['id'], BaseApi::getCurrentUserID())->toArray();
780
781                                 if ($type == 'xml') {
782                                         $userlist[$k++ . ':user'] = $user_info;
783                                 } else {
784                                         $userlist[] = $user_info;
785                                 }
786                         }
787                         $userlist = ['users' => $userlist];
788                 } else {
789                         throw new NotFoundException('User ' . $_GET['q'] . ' not found.');
790                 }
791         } else {
792                 throw new BadRequestException('No search term specified.');
793         }
794
795         return DI::apiResponse()->formatData('users', $type, $userlist);
796 }
797
798 /// @TODO move to top of file or somewhere better
799 api_register_func('api/users/search', 'api_users_search');
800
801 /**
802  * Return user objects
803  *
804  * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup
805  *
806  * @param string $type Return format: json or xml
807  *
808  * @return array|string
809  * @throws BadRequestException
810  * @throws ImagickException
811  * @throws InternalServerErrorException
812  * @throws NotFoundException if the results are empty.
813  * @throws UnauthorizedException
814  */
815 function api_users_lookup($type)
816 {
817         $users = [];
818
819         if (!empty($_REQUEST['user_id'])) {
820                 foreach (explode(',', $_REQUEST['user_id']) as $cid) {
821                         if (!empty($cid) && is_numeric($cid)) {
822                                 $users[] = DI::twitterUser()->createFromContactId((int)$cid, BaseApi::getCurrentUserID())->toArray();
823                         }
824                 }
825         }
826
827         if (empty($users)) {
828                 throw new NotFoundException;
829         }
830
831         return DI::apiResponse()->formatData("users", $type, ['users' => $users]);
832 }
833
834 /// @TODO move to top of file or somewhere better
835 api_register_func('api/users/lookup', 'api_users_lookup', true);
836
837 /**
838  * Returns statuses that match a specified query.
839  *
840  * @see https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets
841  *
842  * @param string $type Return format: json, xml, atom, rss
843  *
844  * @return array|string
845  * @throws BadRequestException if the "q" parameter is missing.
846  * @throws ForbiddenException
847  * @throws ImagickException
848  * @throws InternalServerErrorException
849  * @throws UnauthorizedException
850  */
851 function api_search($type)
852 {
853         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
854
855         if (empty($_REQUEST['q'])) {
856                 throw new BadRequestException('q parameter is required.');
857         }
858
859         $searchTerm = trim(rawurldecode($_REQUEST['q']));
860
861         $data = [];
862         $data['status'] = [];
863         $count = 15;
864         $exclude_replies = !empty($_REQUEST['exclude_replies']);
865         if (!empty($_REQUEST['rpp'])) {
866                 $count = $_REQUEST['rpp'];
867         } elseif (!empty($_REQUEST['count'])) {
868                 $count = $_REQUEST['count'];
869         }
870
871         $since_id = $_REQUEST['since_id'] ?? 0;
872         $max_id = $_REQUEST['max_id'] ?? 0;
873         $page = $_REQUEST['page'] ?? 1;
874
875         $start = max(0, ($page - 1) * $count);
876
877         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
878         if (preg_match('/^#(\w+)$/', $searchTerm, $matches) === 1 && isset($matches[1])) {
879                 $searchTerm = $matches[1];
880                 $condition = ["`iid` > ? AND `name` = ? AND (NOT `private` OR (`private` AND `uid` = ?))", $since_id, $searchTerm, BaseApi::getCurrentUserID()];
881                 $tags = DBA::select('tag-search-view', ['uri-id'], $condition);
882                 $uriids = [];
883                 while ($tag = DBA::fetch($tags)) {
884                         $uriids[] = $tag['uri-id'];
885                 }
886                 DBA::close($tags);
887
888                 if (empty($uriids)) {
889                         return DI::apiResponse()->formatData('statuses', $type, $data);
890                 }
891
892                 $condition = ['uri-id' => $uriids];
893                 if ($exclude_replies) {
894                         $condition['gravity'] = GRAVITY_PARENT;
895                 }
896
897                 $params['group_by'] = ['uri-id'];
898         } else {
899                 $condition = ["`id` > ?
900                         " . ($exclude_replies ? " AND `gravity` = " . GRAVITY_PARENT : ' ') . "
901                         AND (`uid` = 0 OR (`uid` = ? AND NOT `global`))
902                         AND `body` LIKE CONCAT('%',?,'%')",
903                         $since_id, BaseApi::getCurrentUserID(), $_REQUEST['q']];
904                 if ($max_id > 0) {
905                         $condition[0] .= ' AND `id` <= ?';
906                         $condition[] = $max_id;
907                 }
908         }
909
910         $statuses = [];
911
912         if (parse_url($searchTerm, PHP_URL_SCHEME) != '') {
913                 $id = Item::fetchByLink($searchTerm, BaseApi::getCurrentUserID());
914                 if (!$id) {
915                         // Public post
916                         $id = Item::fetchByLink($searchTerm);
917                 }
918
919                 if (!empty($id)) {
920                         $statuses = Post::select([], ['id' => $id]);
921                 }
922         }
923
924         $statuses = $statuses ?: Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
925
926         $data['status'] = api_format_items(Post::toArray($statuses), $type);
927
928         bindComments($data['status']);
929
930         return DI::apiResponse()->formatData('statuses', $type, $data);
931 }
932
933 /// @TODO move to top of file or somewhere better
934 api_register_func('api/search/tweets', 'api_search', true);
935 api_register_func('api/search', 'api_search', true);
936
937 /**
938  * Returns the most recent statuses posted by the user and the users they follow.
939  *
940  * @see  https://developer.twitter.com/en/docs/tweets/timelines/api-reference/get-statuses-home_timeline
941  *
942  * @param string $type Return type (atom, rss, xml, json)
943  *
944  * @return array|string
945  * @throws BadRequestException
946  * @throws ForbiddenException
947  * @throws ImagickException
948  * @throws InternalServerErrorException
949  * @throws UnauthorizedException
950  * @todo Optional parameters
951  * @todo Add reply info
952  */
953 function api_statuses_home_timeline($type)
954 {
955         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
956
957         unset($_REQUEST['user_id']);
958         unset($_GET['user_id']);
959
960         unset($_REQUEST['screen_name']);
961         unset($_GET['screen_name']);
962
963         // get last network messages
964
965         // params
966         $count = $_REQUEST['count'] ?? 20;
967         $page = $_REQUEST['page']?? 0;
968         $since_id = $_REQUEST['since_id'] ?? 0;
969         $max_id = $_REQUEST['max_id'] ?? 0;
970         $exclude_replies = !empty($_REQUEST['exclude_replies']);
971         $conversation_id = $_REQUEST['conversation_id'] ?? 0;
972
973         $start = max(0, ($page - 1) * $count);
974
975         $condition = ["`uid` = ? AND `gravity` IN (?, ?) AND `id` > ?",
976                 BaseApi::getCurrentUserID(), GRAVITY_PARENT, GRAVITY_COMMENT, $since_id];
977
978         if ($max_id > 0) {
979                 $condition[0] .= " AND `id` <= ?";
980                 $condition[] = $max_id;
981         }
982         if ($exclude_replies) {
983                 $condition[0] .= ' AND `gravity` = ?';
984                 $condition[] = GRAVITY_PARENT;
985         }
986         if ($conversation_id > 0) {
987                 $condition[0] .= " AND `parent` = ?";
988                 $condition[] = $conversation_id;
989         }
990
991         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
992         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
993
994         $items = Post::toArray($statuses);
995
996         $ret = api_format_items($items, $type);
997
998         // Set all posts from the query above to seen
999         $idarray = [];
1000         foreach ($items as $item) {
1001                 $idarray[] = intval($item["id"]);
1002         }
1003
1004         if (!empty($idarray)) {
1005                 $unseen = Post::exists(['unseen' => true, 'id' => $idarray]);
1006                 if ($unseen) {
1007                         Item::update(['unseen' => false], ['unseen' => true, 'id' => $idarray]);
1008                 }
1009         }
1010
1011         bindComments($ret);
1012
1013         $data = ['status' => $ret];
1014         switch ($type) {
1015                 case "atom":
1016                         break;
1017                 case "rss":
1018                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1019                         break;
1020         }
1021
1022         return DI::apiResponse()->formatData("statuses", $type, $data);
1023 }
1024
1025
1026 /// @TODO move to top of file or somewhere better
1027 api_register_func('api/statuses/home_timeline', 'api_statuses_home_timeline', true);
1028 api_register_func('api/statuses/friends_timeline', 'api_statuses_home_timeline', true);
1029
1030 /**
1031  * Returns the most recent statuses from public users.
1032  *
1033  * @param string $type Return type (atom, rss, xml, json)
1034  *
1035  * @return array|string
1036  * @throws BadRequestException
1037  * @throws ForbiddenException
1038  * @throws ImagickException
1039  * @throws InternalServerErrorException
1040  * @throws UnauthorizedException
1041  */
1042 function api_statuses_public_timeline($type)
1043 {
1044         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1045
1046         // get last network messages
1047
1048         // params
1049         $count = $_REQUEST['count'] ?? 20;
1050         $page = $_REQUEST['page'] ?? 1;
1051         $since_id = $_REQUEST['since_id'] ?? 0;
1052         $max_id = $_REQUEST['max_id'] ?? 0;
1053         $exclude_replies = (!empty($_REQUEST['exclude_replies']) ? 1 : 0);
1054         $conversation_id = $_REQUEST['conversation_id'] ?? 0;
1055
1056         $start = max(0, ($page - 1) * $count);
1057
1058         if ($exclude_replies && !$conversation_id) {
1059                 $condition = ["`gravity` = ? AND `id` > ? AND `private` = ? AND `wall` AND NOT `author-hidden`",
1060                         GRAVITY_PARENT, $since_id, Item::PUBLIC];
1061
1062                 if ($max_id > 0) {
1063                         $condition[0] .= " AND `id` <= ?";
1064                         $condition[] = $max_id;
1065                 }
1066
1067                 $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1068                 $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1069
1070                 $r = Post::toArray($statuses);
1071         } else {
1072                 $condition = ["`gravity` IN (?, ?) AND `id` > ? AND `private` = ? AND `wall` AND `origin` AND NOT `author-hidden`",
1073                         GRAVITY_PARENT, GRAVITY_COMMENT, $since_id, Item::PUBLIC];
1074
1075                 if ($max_id > 0) {
1076                         $condition[0] .= " AND `id` <= ?";
1077                         $condition[] = $max_id;
1078                 }
1079                 if ($conversation_id > 0) {
1080                         $condition[0] .= " AND `parent` = ?";
1081                         $condition[] = $conversation_id;
1082                 }
1083
1084                 $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1085                 $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1086
1087                 $r = Post::toArray($statuses);
1088         }
1089
1090         $ret = api_format_items($r, $type);
1091
1092         bindComments($ret);
1093
1094         $data = ['status' => $ret];
1095         switch ($type) {
1096                 case "atom":
1097                         break;
1098                 case "rss":
1099                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1100                         break;
1101         }
1102
1103         return DI::apiResponse()->formatData("statuses", $type, $data);
1104 }
1105
1106 /// @TODO move to top of file or somewhere better
1107 api_register_func('api/statuses/public_timeline', 'api_statuses_public_timeline', true);
1108
1109 /**
1110  * Returns the most recent statuses posted by users this node knows about.
1111  *
1112  * @param string $type Return format: json, xml, atom, rss
1113  * @return array|string
1114  * @throws BadRequestException
1115  * @throws ForbiddenException
1116  * @throws ImagickException
1117  * @throws InternalServerErrorException
1118  * @throws UnauthorizedException
1119  */
1120 function api_statuses_networkpublic_timeline($type)
1121 {
1122         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1123
1124         $since_id        = $_REQUEST['since_id'] ?? 0;
1125         $max_id          = $_REQUEST['max_id'] ?? 0;
1126
1127         // pagination
1128         $count = $_REQUEST['count'] ?? 20;
1129         $page  = $_REQUEST['page'] ?? 1;
1130
1131         $start = max(0, ($page - 1) * $count);
1132
1133         $condition = ["`uid` = 0 AND `gravity` IN (?, ?) AND `id` > ? AND `private` = ?",
1134                 GRAVITY_PARENT, GRAVITY_COMMENT, $since_id, Item::PUBLIC];
1135
1136         if ($max_id > 0) {
1137                 $condition[0] .= " AND `id` <= ?";
1138                 $condition[] = $max_id;
1139         }
1140
1141         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1142         $statuses = Post::toArray(Post::selectForUser(BaseApi::getCurrentUserID(), Item::DISPLAY_FIELDLIST, $condition, $params));
1143
1144         $ret = api_format_items($statuses, $type);
1145
1146         bindComments($ret);
1147
1148         $data = ['status' => $ret];
1149         switch ($type) {
1150                 case "atom":
1151                         break;
1152                 case "rss":
1153                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1154                         break;
1155         }
1156
1157         return DI::apiResponse()->formatData("statuses", $type, $data);
1158 }
1159
1160 /// @TODO move to top of file or somewhere better
1161 api_register_func('api/statuses/networkpublic_timeline', 'api_statuses_networkpublic_timeline', true);
1162
1163 /**
1164  * Returns a single status.
1165  *
1166  * @param string $type Return type (atom, rss, xml, json)
1167  *
1168  * @return array|string
1169  * @throws BadRequestException
1170  * @throws ForbiddenException
1171  * @throws ImagickException
1172  * @throws InternalServerErrorException
1173  * @throws UnauthorizedException
1174  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/get-statuses-show-id
1175  */
1176 function api_statuses_show($type)
1177 {
1178         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1179
1180         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
1181
1182         // params
1183         $id = intval(DI::args()->getArgv()[3] ?? 0);
1184
1185         if ($id == 0) {
1186                 $id = intval($_REQUEST['id'] ?? 0);
1187         }
1188
1189         // Hotot workaround
1190         if ($id == 0) {
1191                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1192         }
1193
1194         logger::notice('API: api_statuses_show: ' . $id);
1195
1196         $conversation = !empty($_REQUEST['conversation']);
1197
1198         // try to fetch the item for the local user - or the public item, if there is no local one
1199         $uri_item = Post::selectFirst(['uri-id'], ['id' => $id]);
1200         if (!DBA::isResult($uri_item)) {
1201                 throw new BadRequestException(sprintf("There is no status with the id %d", $id));
1202         }
1203
1204         $item = Post::selectFirst(['id'], ['uri-id' => $uri_item['uri-id'], 'uid' => [0, BaseApi::getCurrentUserID()]], ['order' => ['uid' => true]]);
1205         if (!DBA::isResult($item)) {
1206                 throw new BadRequestException(sprintf("There is no status with the uri-id %d for the given user.", $uri_item['uri-id']));
1207         }
1208
1209         $id = $item['id'];
1210
1211         if ($conversation) {
1212                 $condition = ['parent' => $id, 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT]];
1213                 $params = ['order' => ['id' => true]];
1214         } else {
1215                 $condition = ['id' => $id, 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT]];
1216                 $params = [];
1217         }
1218
1219         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1220
1221         /// @TODO How about copying this to above methods which don't check $r ?
1222         if (!DBA::isResult($statuses)) {
1223                 throw new BadRequestException(sprintf("There is no status or conversation with the id %d.", $id));
1224         }
1225
1226         $ret = api_format_items(Post::toArray($statuses), $type);
1227
1228         if ($conversation) {
1229                 $data = ['status' => $ret];
1230                 return DI::apiResponse()->formatData("statuses", $type, $data);
1231         } else {
1232                 $data = ['status' => $ret[0]];
1233                 return DI::apiResponse()->formatData("status", $type, $data);
1234         }
1235 }
1236
1237 /// @TODO move to top of file or somewhere better
1238 api_register_func('api/statuses/show', 'api_statuses_show', true);
1239
1240 /**
1241  *
1242  * @param string $type Return type (atom, rss, xml, json)
1243  *
1244  * @return array|string
1245  * @throws BadRequestException
1246  * @throws ForbiddenException
1247  * @throws ImagickException
1248  * @throws InternalServerErrorException
1249  * @throws UnauthorizedException
1250  * @todo nothing to say?
1251  */
1252 function api_conversation_show($type)
1253 {
1254         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1255
1256         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
1257
1258         // params
1259         $id       = intval(DI::args()->getArgv()[3]           ?? 0);
1260         $since_id = intval($_REQUEST['since_id'] ?? 0);
1261         $max_id   = intval($_REQUEST['max_id']   ?? 0);
1262         $count    = intval($_REQUEST['count']    ?? 20);
1263         $page     = intval($_REQUEST['page']     ?? 1);
1264
1265         $start = max(0, ($page - 1) * $count);
1266
1267         if ($id == 0) {
1268                 $id = intval($_REQUEST['id'] ?? 0);
1269         }
1270
1271         // Hotot workaround
1272         if ($id == 0) {
1273                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1274         }
1275
1276         Logger::info(API_LOG_PREFIX . '{subaction}', ['module' => 'api', 'action' => 'conversation', 'subaction' => 'show', 'id' => $id]);
1277
1278         // try to fetch the item for the local user - or the public item, if there is no local one
1279         $item = Post::selectFirst(['parent-uri-id'], ['id' => $id]);
1280         if (!DBA::isResult($item)) {
1281                 throw new BadRequestException("There is no status with the id $id.");
1282         }
1283
1284         $parent = Post::selectFirst(['id'], ['uri-id' => $item['parent-uri-id'], 'uid' => [0, BaseApi::getCurrentUserID()]], ['order' => ['uid' => true]]);
1285         if (!DBA::isResult($parent)) {
1286                 throw new BadRequestException("There is no status with this id.");
1287         }
1288
1289         $id = $parent['id'];
1290
1291         $condition = ["`parent` = ? AND `uid` IN (0, ?) AND `gravity` IN (?, ?) AND `id` > ?",
1292                 $id, BaseApi::getCurrentUserID(), GRAVITY_PARENT, GRAVITY_COMMENT, $since_id];
1293
1294         if ($max_id > 0) {
1295                 $condition[0] .= " AND `id` <= ?";
1296                 $condition[] = $max_id;
1297         }
1298
1299         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1300         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1301
1302         if (!DBA::isResult($statuses)) {
1303                 throw new BadRequestException("There is no status with id $id.");
1304         }
1305
1306         $ret = api_format_items(Post::toArray($statuses), $type);
1307
1308         $data = ['status' => $ret];
1309         return DI::apiResponse()->formatData("statuses", $type, $data);
1310 }
1311
1312 /// @TODO move to top of file or somewhere better
1313 api_register_func('api/conversation/show', 'api_conversation_show', true);
1314 api_register_func('api/statusnet/conversation', 'api_conversation_show', true);
1315
1316 /**
1317  * Repeats a status.
1318  *
1319  * @param string $type Return type (atom, rss, xml, json)
1320  *
1321  * @return array|string
1322  * @throws BadRequestException
1323  * @throws ForbiddenException
1324  * @throws ImagickException
1325  * @throws InternalServerErrorException
1326  * @throws UnauthorizedException
1327  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-retweet-id
1328  */
1329 function api_statuses_repeat($type)
1330 {
1331         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1332
1333         // params
1334         $id = intval(DI::args()->getArgv()[3] ?? 0);
1335
1336         if ($id == 0) {
1337                 $id = intval($_REQUEST['id'] ?? 0);
1338         }
1339
1340         // Hotot workaround
1341         if ($id == 0) {
1342                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1343         }
1344
1345         logger::notice('API: api_statuses_repeat: ' . $id);
1346
1347         $fields = ['uri-id', 'network', 'body', 'title', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink'];
1348         $item = Post::selectFirst($fields, ['id' => $id, 'private' => [Item::PUBLIC, Item::UNLISTED]]);
1349
1350         if (DBA::isResult($item) && !empty($item['body'])) {
1351                 if (in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::TWITTER])) {
1352                         if (!Item::performActivity($id, 'announce', BaseApi::getCurrentUserID())) {
1353                                 throw new InternalServerErrorException();
1354                         }
1355
1356                         $item_id = $id;
1357                 } else {
1358                         if (strpos($item['body'], "[/share]") !== false) {
1359                                 $pos = strpos($item['body'], "[share");
1360                                 $post = substr($item['body'], $pos);
1361                         } else {
1362                                 $post = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']);
1363
1364                                 if (!empty($item['title'])) {
1365                                         $post .= '[h3]' . $item['title'] . "[/h3]\n";
1366                                 }
1367
1368                                 $post .= $item['body'];
1369                                 $post .= "[/share]";
1370                         }
1371                         $_REQUEST['body'] = $post;
1372                         $_REQUEST['profile_uid'] = BaseApi::getCurrentUserID();
1373                         $_REQUEST['api_source'] = true;
1374
1375                         if (empty($_REQUEST['source'])) {
1376                                 $_REQUEST['source'] = api_source();
1377                         }
1378
1379                         $item_id = item_post(DI::app());
1380                 }
1381         } else {
1382                 throw new ForbiddenException();
1383         }
1384
1385         // output the post that we just posted.
1386         return api_status_show($type, $item_id);
1387 }
1388
1389 /// @TODO move to top of file or somewhere better
1390 api_register_func('api/statuses/retweet', 'api_statuses_repeat', true, API_METHOD_POST);
1391
1392 /**
1393  * Destroys a specific status.
1394  *
1395  * @param string $type Return type (atom, rss, xml, json)
1396  *
1397  * @return array|string
1398  * @throws BadRequestException
1399  * @throws ForbiddenException
1400  * @throws ImagickException
1401  * @throws InternalServerErrorException
1402  * @throws UnauthorizedException
1403  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-destroy-id
1404  */
1405 function api_statuses_destroy($type)
1406 {
1407         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1408
1409         // params
1410         $id = intval(DI::args()->getArgv()[3] ?? 0);
1411
1412         if ($id == 0) {
1413                 $id = intval($_REQUEST['id'] ?? 0);
1414         }
1415
1416         // Hotot workaround
1417         if ($id == 0) {
1418                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1419         }
1420
1421         logger::notice('API: api_statuses_destroy: ' . $id);
1422
1423         $ret = api_statuses_show($type);
1424
1425         Item::deleteForUser(['id' => $id], BaseApi::getCurrentUserID());
1426
1427         return $ret;
1428 }
1429
1430 /// @TODO move to top of file or somewhere better
1431 api_register_func('api/statuses/destroy', 'api_statuses_destroy', true, API_METHOD_DELETE);
1432
1433 /**
1434  * Returns the most recent mentions.
1435  *
1436  * @param string $type Return type (atom, rss, xml, json)
1437  *
1438  * @return array|string
1439  * @throws BadRequestException
1440  * @throws ForbiddenException
1441  * @throws ImagickException
1442  * @throws InternalServerErrorException
1443  * @throws UnauthorizedException
1444  * @see http://developer.twitter.com/doc/get/statuses/mentions
1445  */
1446 function api_statuses_mentions($type)
1447 {
1448         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1449
1450         unset($_REQUEST['user_id']);
1451         unset($_GET['user_id']);
1452
1453         unset($_REQUEST['screen_name']);
1454         unset($_GET['screen_name']);
1455
1456         // get last network messages
1457
1458         // params
1459         $since_id = intval($_REQUEST['since_id'] ?? 0);
1460         $max_id   = intval($_REQUEST['max_id']   ?? 0);
1461         $count    = intval($_REQUEST['count']    ?? 20);
1462         $page     = intval($_REQUEST['page']     ?? 1);
1463
1464         $start = max(0, ($page - 1) * $count);
1465
1466         $query = "`gravity` IN (?, ?) AND `uri-id` IN
1467                 (SELECT `uri-id` FROM `post-user-notification` WHERE `uid` = ? AND `notification-type` & ? != 0 ORDER BY `uri-id`)
1468                 AND (`uid` = 0 OR (`uid` = ? AND NOT `global`)) AND `id` > ?";
1469
1470         $condition = [
1471                 GRAVITY_PARENT, GRAVITY_COMMENT,
1472                 BaseApi::getCurrentUserID(),
1473                 Post\UserNotification::TYPE_EXPLICIT_TAGGED | Post\UserNotification::TYPE_IMPLICIT_TAGGED |
1474                 Post\UserNotification::TYPE_THREAD_COMMENT | Post\UserNotification::TYPE_DIRECT_COMMENT |
1475                 Post\UserNotification::TYPE_DIRECT_THREAD_COMMENT,
1476                 BaseApi::getCurrentUserID(), $since_id,
1477         ];
1478
1479         if ($max_id > 0) {
1480                 $query .= " AND `id` <= ?";
1481                 $condition[] = $max_id;
1482         }
1483
1484         array_unshift($condition, $query);
1485
1486         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1487         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1488
1489         $ret = api_format_items(Post::toArray($statuses), $type);
1490
1491         $data = ['status' => $ret];
1492         switch ($type) {
1493                 case "atom":
1494                         break;
1495                 case "rss":
1496                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1497                         break;
1498         }
1499
1500         return DI::apiResponse()->formatData("statuses", $type, $data);
1501 }
1502
1503 /// @TODO move to top of file or somewhere better
1504 api_register_func('api/statuses/mentions', 'api_statuses_mentions', true);
1505 api_register_func('api/statuses/replies', 'api_statuses_mentions', true);
1506
1507 /**
1508  * Returns the most recent statuses posted by the user.
1509  *
1510  * @param string $type Either "json" or "xml"
1511  * @return string|array
1512  * @throws BadRequestException
1513  * @throws ForbiddenException
1514  * @throws ImagickException
1515  * @throws InternalServerErrorException
1516  * @throws UnauthorizedException
1517  * @see   https://developer.twitter.com/en/docs/tweets/timelines/api-reference/get-statuses-user_timeline
1518  */
1519 function api_statuses_user_timeline($type)
1520 {
1521         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1522
1523         $uid = BaseApi::getCurrentUserID();
1524
1525         Logger::info('api_statuses_user_timeline', ['api_user' => $uid, '_REQUEST' => $_REQUEST]);
1526
1527         $cid             = BaseApi::getContactIDForSearchterm($_REQUEST['screen_name'] ?? '', $_REQUEST['user_id'] ?? 0, $uid);
1528         $since_id        = $_REQUEST['since_id'] ?? 0;
1529         $max_id          = $_REQUEST['max_id'] ?? 0;
1530         $exclude_replies = !empty($_REQUEST['exclude_replies']);
1531         $conversation_id = $_REQUEST['conversation_id'] ?? 0;
1532
1533         // pagination
1534         $count = $_REQUEST['count'] ?? 20;
1535         $page  = $_REQUEST['page'] ?? 1;
1536
1537         $start = max(0, ($page - 1) * $count);
1538
1539         $condition = ["(`uid` = ? OR (`uid` = ? AND NOT `global`)) AND `gravity` IN (?, ?) AND `id` > ? AND `author-id` = ?",
1540                 0, $uid, GRAVITY_PARENT, GRAVITY_COMMENT, $since_id, $cid];
1541
1542         if ($exclude_replies) {
1543                 $condition[0] .= ' AND `gravity` = ?';
1544                 $condition[] = GRAVITY_PARENT;
1545         }
1546
1547         if ($conversation_id > 0) {
1548                 $condition[0] .= " AND `parent` = ?";
1549                 $condition[] = $conversation_id;
1550         }
1551
1552         if ($max_id > 0) {
1553                 $condition[0] .= " AND `id` <= ?";
1554                 $condition[] = $max_id;
1555         }
1556         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1557         $statuses = Post::selectForUser($uid, [], $condition, $params);
1558
1559         $ret = api_format_items(Post::toArray($statuses), $type);
1560
1561         bindComments($ret);
1562
1563         $data = ['status' => $ret];
1564         switch ($type) {
1565                 case "atom":
1566                         break;
1567                 case "rss":
1568                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId($uid)->toArray());
1569                         break;
1570         }
1571
1572         return DI::apiResponse()->formatData("statuses", $type, $data);
1573 }
1574
1575 /// @TODO move to top of file or somewhere better
1576 api_register_func('api/statuses/user_timeline', 'api_statuses_user_timeline', true);
1577
1578 /**
1579  * Star/unstar an item.
1580  * param: id : id of the item
1581  *
1582  * @param string $type Return type (atom, rss, xml, json)
1583  *
1584  * @return array|string
1585  * @throws BadRequestException
1586  * @throws ForbiddenException
1587  * @throws ImagickException
1588  * @throws InternalServerErrorException
1589  * @throws UnauthorizedException
1590  * @see https://web.archive.org/web/20131019055350/https://dev.twitter.com/docs/api/1/post/favorites/create/%3Aid
1591  */
1592 function api_favorites_create_destroy($type)
1593 {
1594         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1595
1596         // for versioned api.
1597         /// @TODO We need a better global soluton
1598         $action_argv_id = 2;
1599         if (count(DI::args()->getArgv()) > 1 && DI::args()->getArgv()[1] == "1.1") {
1600                 $action_argv_id = 3;
1601         }
1602
1603         if (DI::args()->getArgc() <= $action_argv_id) {
1604                 throw new BadRequestException("Invalid request.");
1605         }
1606         $action = str_replace("." . $type, "", DI::args()->getArgv()[$action_argv_id]);
1607         if (DI::args()->getArgc() == $action_argv_id + 2) {
1608                 $itemid = intval(DI::args()->getArgv()[$action_argv_id + 1] ?? 0);
1609         } else {
1610                 $itemid = intval($_REQUEST['id'] ?? 0);
1611         }
1612
1613         $item = Post::selectFirstForUser(BaseApi::getCurrentUserID(), [], ['id' => $itemid, 'uid' => BaseApi::getCurrentUserID()]);
1614
1615         if (!DBA::isResult($item)) {
1616                 throw new BadRequestException("Invalid item.");
1617         }
1618
1619         switch ($action) {
1620                 case "create":
1621                         $item['starred'] = 1;
1622                         break;
1623                 case "destroy":
1624                         $item['starred'] = 0;
1625                         break;
1626                 default:
1627                         throw new BadRequestException("Invalid action ".$action);
1628         }
1629
1630         $r = Item::update(['starred' => $item['starred']], ['id' => $itemid]);
1631
1632         if ($r === false) {
1633                 throw new InternalServerErrorException("DB error");
1634         }
1635
1636         $ret = api_format_item($item, $type);
1637
1638         $data = ['status' => $ret];
1639         switch ($type) {
1640                 case "atom":
1641                         break;
1642                 case "rss":
1643                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1644                         break;
1645         }
1646
1647         return DI::apiResponse()->formatData("status", $type, $data);
1648 }
1649
1650 /// @TODO move to top of file or somewhere better
1651 api_register_func('api/favorites/create', 'api_favorites_create_destroy', true, API_METHOD_POST);
1652 api_register_func('api/favorites/destroy', 'api_favorites_create_destroy', true, API_METHOD_DELETE);
1653
1654 /**
1655  * Returns the most recent favorite statuses.
1656  *
1657  * @param string $type Return type (atom, rss, xml, json)
1658  *
1659  * @return string|array
1660  * @throws BadRequestException
1661  * @throws ForbiddenException
1662  * @throws ImagickException
1663  * @throws InternalServerErrorException
1664  * @throws UnauthorizedException
1665  */
1666 function api_favorites($type)
1667 {
1668         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1669
1670         // in friendica starred item are private
1671         // return favorites only for self
1672         Logger::info(API_LOG_PREFIX . 'for {self}', ['module' => 'api', 'action' => 'favorites']);
1673
1674         // params
1675         $since_id = $_REQUEST['since_id'] ?? 0;
1676         $max_id = $_REQUEST['max_id'] ?? 0;
1677         $count = $_GET['count'] ?? 20;
1678         $page = $_REQUEST['page'] ?? 1;
1679
1680         $start = max(0, ($page - 1) * $count);
1681
1682         $condition = ["`uid` = ? AND `gravity` IN (?, ?) AND `id` > ? AND `starred`",
1683                 BaseApi::getCurrentUserID(), GRAVITY_PARENT, GRAVITY_COMMENT, $since_id];
1684
1685         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
1686
1687         if ($max_id > 0) {
1688                 $condition[0] .= " AND `id` <= ?";
1689                 $condition[] = $max_id;
1690         }
1691
1692         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
1693
1694         $ret = api_format_items(Post::toArray($statuses), $type);
1695
1696         bindComments($ret);
1697
1698         $data = ['status' => $ret];
1699         switch ($type) {
1700                 case "atom":
1701                         break;
1702                 case "rss":
1703                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
1704                         break;
1705         }
1706
1707         return DI::apiResponse()->formatData("statuses", $type, $data);
1708 }
1709
1710 /// @TODO move to top of file or somewhere better
1711 api_register_func('api/favorites', 'api_favorites', true);
1712
1713 /**
1714  *
1715  * @param array $item
1716  * @param array $recipient
1717  * @param array $sender
1718  *
1719  * @return array
1720  * @throws InternalServerErrorException
1721  */
1722 function api_format_messages($item, $recipient, $sender)
1723 {
1724         // standard meta information
1725         $ret = [
1726                 'id'                    => $item['id'],
1727                 'sender_id'             => $sender['id'],
1728                 'text'                  => "",
1729                 'recipient_id'          => $recipient['id'],
1730                 'created_at'            => DateTimeFormat::utc($item['created'] ?? 'now', DateTimeFormat::API),
1731                 'sender_screen_name'    => $sender['screen_name'],
1732                 'recipient_screen_name' => $recipient['screen_name'],
1733                 'sender'                => $sender,
1734                 'recipient'             => $recipient,
1735                 'title'                 => "",
1736                 'friendica_seen'        => $item['seen'] ?? 0,
1737                 'friendica_parent_uri'  => $item['parent-uri'] ?? '',
1738         ];
1739
1740         // "uid" and "self" are only needed for some internal stuff, so remove it from here
1741         if (isset($ret['sender']['uid'])) {
1742                 unset($ret['sender']['uid']);
1743         }
1744         if (isset($ret['sender']['self'])) {
1745                 unset($ret['sender']['self']);
1746         }
1747         if (isset($ret['recipient']['uid'])) {
1748                 unset($ret['recipient']['uid']);
1749         }
1750         if (isset($ret['recipient']['self'])) {
1751                 unset($ret['recipient']['self']);
1752         }
1753
1754         //don't send title to regular StatusNET requests to avoid confusing these apps
1755         if (!empty($_GET['getText'])) {
1756                 $ret['title'] = $item['title'];
1757                 if ($_GET['getText'] == 'html') {
1758                         $ret['text'] = BBCode::convertForUriId($item['uri-id'], $item['body'], BBCode::API);
1759                 } elseif ($_GET['getText'] == 'plain') {
1760                         $ret['text'] = trim(HTML::toPlaintext(BBCode::convertForUriId($item['uri-id'], api_clean_plain_items($item['body']), BBCode::API), 0));
1761                 }
1762         } else {
1763                 $ret['text'] = $item['title'] . "\n" . HTML::toPlaintext(BBCode::convertForUriId($item['uri-id'], api_clean_plain_items($item['body']), BBCode::API), 0);
1764         }
1765         if (!empty($_GET['getUserObjects']) && $_GET['getUserObjects'] == 'false') {
1766                 unset($ret['sender']);
1767                 unset($ret['recipient']);
1768         }
1769
1770         return $ret;
1771 }
1772
1773 /**
1774  *
1775  * @param array $item
1776  *
1777  * @return array
1778  * @throws InternalServerErrorException
1779  */
1780 function api_convert_item($item)
1781 {
1782         $body = api_add_attachments_to_body($item);
1783
1784         $entities = api_get_entitities($statustext, $body, $item['uri-id']);
1785
1786         // Add pictures to the attachment array and remove them from the body
1787         $attachments = api_get_attachments($body, $item['uri-id']);
1788
1789         // Workaround for ostatus messages where the title is identically to the body
1790         $html = BBCode::convertForUriId($item['uri-id'], api_clean_plain_items($body), BBCode::API);
1791         $statusbody = trim(HTML::toPlaintext($html, 0));
1792
1793         // handle data: images
1794         $statusbody = api_format_items_embeded_images($item, $statusbody);
1795
1796         $statustitle = trim($item['title']);
1797
1798         if (($statustitle != '') && (strpos($statusbody, $statustitle) !== false)) {
1799                 $statustext = trim($statusbody);
1800         } else {
1801                 $statustext = trim($statustitle."\n\n".$statusbody);
1802         }
1803
1804         if ((($item['network'] ?? Protocol::PHANTOM) == Protocol::FEED) && (mb_strlen($statustext)> 1000)) {
1805                 $statustext = mb_substr($statustext, 0, 1000) . "... \n" . ($item['plink'] ?? '');
1806         }
1807
1808         $statushtml = BBCode::convertForUriId($item['uri-id'], BBCode::removeAttachment($body), BBCode::API);
1809
1810         // Workaround for clients with limited HTML parser functionality
1811         $search = ["<br>", "<blockquote>", "</blockquote>",
1812                         "<h1>", "</h1>", "<h2>", "</h2>",
1813                         "<h3>", "</h3>", "<h4>", "</h4>",
1814                         "<h5>", "</h5>", "<h6>", "</h6>"];
1815         $replace = ["<br>", "<br><blockquote>", "</blockquote><br>",
1816                         "<br><h1>", "</h1><br>", "<br><h2>", "</h2><br>",
1817                         "<br><h3>", "</h3><br>", "<br><h4>", "</h4><br>",
1818                         "<br><h5>", "</h5><br>", "<br><h6>", "</h6><br>"];
1819         $statushtml = str_replace($search, $replace, $statushtml);
1820
1821         if ($item['title'] != "") {
1822                 $statushtml = "<br><h4>" . BBCode::convertForUriId($item['uri-id'], $item['title']) . "</h4><br>" . $statushtml;
1823         }
1824
1825         do {
1826                 $oldtext = $statushtml;
1827                 $statushtml = str_replace("<br><br>", "<br>", $statushtml);
1828         } while ($oldtext != $statushtml);
1829
1830         if (substr($statushtml, 0, 4) == '<br>') {
1831                 $statushtml = substr($statushtml, 4);
1832         }
1833
1834         if (substr($statushtml, 0, -4) == '<br>') {
1835                 $statushtml = substr($statushtml, -4);
1836         }
1837
1838         // feeds without body should contain the link
1839         if ((($item['network'] ?? Protocol::PHANTOM) == Protocol::FEED) && (strlen($item['body']) == 0)) {
1840                 $statushtml .= BBCode::convertForUriId($item['uri-id'], $item['plink']);
1841         }
1842
1843         return [
1844                 "text" => $statustext,
1845                 "html" => $statushtml,
1846                 "attachments" => $attachments,
1847                 "entities" => $entities
1848         ];
1849 }
1850
1851 /**
1852  * Add media attachments to the body
1853  *
1854  * @param array $item
1855  * @return string body with added media
1856  */
1857 function api_add_attachments_to_body(array $item)
1858 {
1859         $body = Post\Media::addAttachmentsToBody($item['uri-id'], $item['body']);
1860
1861         if (strpos($body, '[/img]') !== false) {
1862                 return $body;
1863         }
1864
1865         foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::HTML]) as $media) {
1866                 if (!empty($media['preview'])) {
1867                         $description = $media['description'] ?: $media['name'];
1868                         if (!empty($description)) {
1869                                 $body .= "\n[img=" . $media['preview'] . ']' . $description .'[/img]';
1870                         } else {
1871                                 $body .= "\n[img]" . $media['preview'] .'[/img]';
1872                         }
1873                 }
1874         }
1875
1876         return $body;
1877 }
1878
1879 /**
1880  *
1881  * @param string $body
1882  * @param int    $uriid
1883  *
1884  * @return array
1885  * @throws InternalServerErrorException
1886  */
1887 function api_get_attachments(&$body, $uriid)
1888 {
1889         $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
1890         $body = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", '[img]$1[/img]', $body);
1891
1892         $URLSearchString = "^\[\]";
1893         if (!preg_match_all("/\[img\]([$URLSearchString]*)\[\/img\]/ism", $body, $images)) {
1894                 return [];
1895         }
1896
1897         // Remove all embedded pictures, since they are added as attachments
1898         foreach ($images[0] as $orig) {
1899                 $body = str_replace($orig, '', $body);
1900         }
1901
1902         $attachments = [];
1903
1904         foreach ($images[1] as $image) {
1905                 $imagedata = Images::getInfoFromURLCached($image);
1906
1907                 if ($imagedata) {
1908                         $attachments[] = ["url" => Post\Link::getByLink($uriid, $image), "mimetype" => $imagedata["mime"], "size" => $imagedata["size"]];
1909                 }
1910         }
1911
1912         return $attachments;
1913 }
1914
1915 /**
1916  *
1917  * @param string $text
1918  * @param string $bbcode
1919  *
1920  * @return array
1921  * @throws InternalServerErrorException
1922  * @todo Links at the first character of the post
1923  */
1924 function api_get_entitities(&$text, $bbcode, $uriid)
1925 {
1926         $include_entities = strtolower($_REQUEST['include_entities'] ?? 'false');
1927
1928         if ($include_entities != "true") {
1929                 preg_match_all("/\[img](.*?)\[\/img\]/ism", $bbcode, $images);
1930
1931                 foreach ($images[1] as $image) {
1932                         $replace = Post\Link::getByLink($uriid, $image);
1933                         $text = str_replace($image, $replace, $text);
1934                 }
1935                 return [];
1936         }
1937
1938         $bbcode = BBCode::cleanPictureLinks($bbcode);
1939
1940         // Change pure links in text to bbcode uris
1941         $bbcode = preg_replace("/([^\]\='".'"'."]|^)(https?\:\/\/[a-zA-Z0-9\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,]+)/ism", '$1[url=$2]$2[/url]', $bbcode);
1942
1943         $entities = [];
1944         $entities["hashtags"] = [];
1945         $entities["symbols"] = [];
1946         $entities["urls"] = [];
1947         $entities["user_mentions"] = [];
1948
1949         $URLSearchString = "^\[\]";
1950
1951         $bbcode = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '#$2', $bbcode);
1952
1953         $bbcode = preg_replace("/\[bookmark\=([$URLSearchString]*)\](.*?)\[\/bookmark\]/ism", '[url=$1]$2[/url]', $bbcode);
1954         $bbcode = preg_replace("/\[video\](.*?)\[\/video\]/ism", '[url=$1]$1[/url]', $bbcode);
1955
1956         $bbcode = preg_replace(
1957                 "/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism",
1958                 '[url=https://www.youtube.com/watch?v=$1]https://www.youtube.com/watch?v=$1[/url]',
1959                 $bbcode
1960         );
1961         $bbcode = preg_replace("/\[youtube\](.*?)\[\/youtube\]/ism", '[url=$1]$1[/url]', $bbcode);
1962
1963         $bbcode = preg_replace(
1964                 "/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism",
1965                 '[url=https://vimeo.com/$1]https://vimeo.com/$1[/url]',
1966                 $bbcode
1967         );
1968         $bbcode = preg_replace("/\[vimeo\](.*?)\[\/vimeo\]/ism", '[url=$1]$1[/url]', $bbcode);
1969
1970         $bbcode = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $bbcode);
1971
1972         preg_match_all("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", $bbcode, $urls);
1973
1974         $ordered_urls = [];
1975         foreach ($urls[1] as $id => $url) {
1976                 $start = iconv_strpos($text, $url, 0, "UTF-8");
1977                 if (!($start === false)) {
1978                         $ordered_urls[$start] = ["url" => $url, "title" => $urls[2][$id]];
1979                 }
1980         }
1981
1982         ksort($ordered_urls);
1983
1984         $offset = 0;
1985
1986         foreach ($ordered_urls as $url) {
1987                 if ((substr($url["title"], 0, 7) != "http://") && (substr($url["title"], 0, 8) != "https://")
1988                         && !strpos($url["title"], "http://") && !strpos($url["title"], "https://")
1989                 ) {
1990                         $display_url = $url["title"];
1991                 } else {
1992                         $display_url = str_replace(["http://www.", "https://www."], ["", ""], $url["url"]);
1993                         $display_url = str_replace(["http://", "https://"], ["", ""], $display_url);
1994
1995                         if (strlen($display_url) > 26) {
1996                                 $display_url = substr($display_url, 0, 25)."…";
1997                         }
1998                 }
1999
2000                 $start = iconv_strpos($text, $url["url"], $offset, "UTF-8");
2001                 if (!($start === false)) {
2002                         $entities["urls"][] = ["url" => $url["url"],
2003                                                         "expanded_url" => $url["url"],
2004                                                         "display_url" => $display_url,
2005                                                         "indices" => [$start, $start+strlen($url["url"])]];
2006                         $offset = $start + 1;
2007                 }
2008         }
2009
2010         preg_match_all("/\[img\=(.*?)\](.*?)\[\/img\]/ism", $bbcode, $images, PREG_SET_ORDER);
2011         $ordered_images = [];
2012         foreach ($images as $image) {
2013                 $start = iconv_strpos($text, $image[1], 0, "UTF-8");
2014                 if (!($start === false)) {
2015                         $ordered_images[$start] = ['url' => $image[1], 'alt' => $image[2]];
2016                 }
2017         }
2018
2019         preg_match_all("/\[img](.*?)\[\/img\]/ism", $bbcode, $images);
2020         foreach ($images[1] as $image) {
2021                 $start = iconv_strpos($text, $image, 0, "UTF-8");
2022                 if (!($start === false)) {
2023                         $ordered_images[$start] = ['url' => $image, 'alt' => ''];
2024                 }
2025         }
2026
2027         $offset = 0;
2028
2029         foreach ($ordered_images as $image) {
2030                 $url = $image['url'];
2031                 $ext_alt_text = $image['alt'];
2032
2033                 $display_url = str_replace(["http://www.", "https://www."], ["", ""], $url);
2034                 $display_url = str_replace(["http://", "https://"], ["", ""], $display_url);
2035
2036                 if (strlen($display_url) > 26) {
2037                         $display_url = substr($display_url, 0, 25)."…";
2038                 }
2039
2040                 $start = iconv_strpos($text, $url, $offset, "UTF-8");
2041                 if (!($start === false)) {
2042                         $image = Images::getInfoFromURLCached($url);
2043                         if ($image) {
2044                                 $media_url = Post\Link::getByLink($uriid, $url);
2045                                 $sizes["medium"] = ["w" => $image[0], "h" => $image[1], "resize" => "fit"];
2046
2047                                 $entities["media"][] = [
2048                                                         "id" => $start+1,
2049                                                         "id_str" => (string) ($start + 1),
2050                                                         "indices" => [$start, $start+strlen($url)],
2051                                                         "media_url" => Strings::normaliseLink($media_url),
2052                                                         "media_url_https" => $media_url,
2053                                                         "url" => $url,
2054                                                         "display_url" => $display_url,
2055                                                         "expanded_url" => $url,
2056                                                         "ext_alt_text" => $ext_alt_text,
2057                                                         "type" => "photo",
2058                                                         "sizes" => $sizes];
2059                         }
2060                         $offset = $start + 1;
2061                 }
2062         }
2063
2064         return $entities;
2065 }
2066
2067 /**
2068  *
2069  * @param array $item
2070  * @param string $text
2071  *
2072  * @return string
2073  */
2074 function api_format_items_embeded_images($item, $text)
2075 {
2076         $text = preg_replace_callback(
2077                 '|data:image/([^;]+)[^=]+=*|m',
2078                 function () use ($item) {
2079                         return DI::baseUrl() . '/display/' . $item['guid'];
2080                 },
2081                 $text
2082         );
2083         return $text;
2084 }
2085
2086 /**
2087  * return <a href='url'>name</a> as array
2088  *
2089  * @param string $txt text
2090  * @return array
2091  *                      'name' => 'name',
2092  *                      'url => 'url'
2093  */
2094 function api_contactlink_to_array($txt)
2095 {
2096         $match = [];
2097         $r = preg_match_all('|<a href="([^"]*)">([^<]*)</a>|', $txt, $match);
2098         if ($r && count($match)==3) {
2099                 $res = [
2100                         'name' => $match[2],
2101                         'url' => $match[1]
2102                 ];
2103         } else {
2104                 $res = [
2105                         'name' => $txt,
2106                         'url' => ""
2107                 ];
2108         }
2109         return $res;
2110 }
2111
2112
2113 /**
2114  * return likes, dislikes and attend status for item
2115  *
2116  * @param array  $item array
2117  * @param string $type Return type (atom, rss, xml, json)
2118  *
2119  * @return array
2120  *            likes => int count,
2121  *            dislikes => int count
2122  * @throws BadRequestException
2123  * @throws ImagickException
2124  * @throws InternalServerErrorException
2125  * @throws UnauthorizedException
2126  */
2127 function api_format_items_activities($item, $type = "json")
2128 {
2129         $activities = [
2130                 'like' => [],
2131                 'dislike' => [],
2132                 'attendyes' => [],
2133                 'attendno' => [],
2134                 'attendmaybe' => [],
2135                 'announce' => [],
2136         ];
2137
2138         $condition = ['uid' => $item['uid'], 'thr-parent' => $item['uri'], 'gravity' => GRAVITY_ACTIVITY];
2139         $ret = Post::selectForUser($item['uid'], ['author-id', 'verb'], $condition);
2140
2141         while ($parent_item = Post::fetch($ret)) {
2142                 // not used as result should be structured like other user data
2143                 //builtin_activity_puller($i, $activities);
2144
2145                 // get user data and add it to the array of the activity
2146                 $user = DI::twitterUser()->createFromContactId($parent_item['author-id'], BaseApi::getCurrentUserID())->toArray();
2147                 switch ($parent_item['verb']) {
2148                         case Activity::LIKE:
2149                                 $activities['like'][] = $user;
2150                                 break;
2151                         case Activity::DISLIKE:
2152                                 $activities['dislike'][] = $user;
2153                                 break;
2154                         case Activity::ATTEND:
2155                                 $activities['attendyes'][] = $user;
2156                                 break;
2157                         case Activity::ATTENDNO:
2158                                 $activities['attendno'][] = $user;
2159                                 break;
2160                         case Activity::ATTENDMAYBE:
2161                                 $activities['attendmaybe'][] = $user;
2162                                 break;
2163                         case Activity::ANNOUNCE:
2164                                 $activities['announce'][] = $user;
2165                                 break;
2166                         default:
2167                                 break;
2168                 }
2169         }
2170
2171         DBA::close($ret);
2172
2173         if ($type == "xml") {
2174                 $xml_activities = [];
2175                 foreach ($activities as $k => $v) {
2176                         // change xml element from "like" to "friendica:like"
2177                         $xml_activities["friendica:".$k] = $v;
2178                         // add user data into xml output
2179                         $k_user = 0;
2180                         foreach ($v as $user) {
2181                                 $xml_activities["friendica:".$k][$k_user++.":user"] = $user;
2182                         }
2183                 }
2184                 $activities = $xml_activities;
2185         }
2186
2187         return $activities;
2188 }
2189
2190 /**
2191  * format items to be returned by api
2192  *
2193  * @param array  $items       array of items
2194  * @param string $type        Return type (atom, rss, xml, json)
2195  * @return array
2196  * @throws BadRequestException
2197  * @throws ImagickException
2198  * @throws InternalServerErrorException
2199  * @throws UnauthorizedException
2200  */
2201 function api_format_items($items, $type = "json")
2202 {
2203         $ret = [];
2204         foreach ($items as $item) {
2205                 $ret[] = api_format_item($item, $type);
2206         }
2207
2208         return $ret;
2209 }
2210
2211 /**
2212  * @param array  $item       Item record
2213  * @param string $type       Return format (atom, rss, xml, json)
2214  * @param array $status_user User record of the item author, can be provided by api_item_get_user()
2215  * @param array $author_user User record of the item author, can be provided by api_item_get_user()
2216  * @param array $owner_user  User record of the item owner, can be provided by api_item_get_user()
2217  * @return array API-formatted status
2218  * @throws BadRequestException
2219  * @throws ImagickException
2220  * @throws InternalServerErrorException
2221  * @throws UnauthorizedException
2222  */
2223 function api_format_item($item, $type = "json")
2224 {
2225         $author_user = DI::twitterUser()->createFromContactId($item['author-id'], BaseApi::getCurrentUserID())->toArray();
2226         $owner_user = DI::twitterUser()->createFromContactId($item['owner-id'], BaseApi::getCurrentUserID())->toArray();
2227
2228         DI::contentItem()->localize($item);
2229
2230         $in_reply_to = api_in_reply_to($item);
2231
2232         $converted = api_convert_item($item);
2233
2234         if ($type == "xml") {
2235                 $geo = "georss:point";
2236         } else {
2237                 $geo = "geo";
2238         }
2239
2240         $status = [
2241                 'text'          => $converted["text"],
2242                 'truncated' => false,
2243                 'created_at'=> DateTimeFormat::utc($item['created'], DateTimeFormat::API),
2244                 'in_reply_to_status_id' => $in_reply_to['status_id'],
2245                 'in_reply_to_status_id_str' => $in_reply_to['status_id_str'],
2246                 'source'    => (($item['app']) ? $item['app'] : 'web'),
2247                 'id'            => intval($item['id']),
2248                 'id_str'        => (string) intval($item['id']),
2249                 'in_reply_to_user_id' => $in_reply_to['user_id'],
2250                 'in_reply_to_user_id_str' => $in_reply_to['user_id_str'],
2251                 'in_reply_to_screen_name' => $in_reply_to['screen_name'],
2252                 $geo => null,
2253                 'favorited' => $item['starred'] ? true : false,
2254                 'user' =>  $author_user,
2255                 'friendica_author' => $author_user,
2256                 'friendica_owner' => $owner_user,
2257                 'friendica_private' => $item['private'] == Item::PRIVATE,
2258                 //'entities' => NULL,
2259                 'statusnet_html' => $converted["html"],
2260                 'statusnet_conversation_id' => $item['parent'],
2261                 'external_url' => DI::baseUrl() . "/display/" . $item['guid'],
2262                 'friendica_activities' => api_format_items_activities($item, $type),
2263                 'friendica_title' => $item['title'],
2264                 'friendica_html' => BBCode::convertForUriId($item['uri-id'], $item['body'], BBCode::EXTERNAL)
2265         ];
2266
2267         if (count($converted["attachments"]) > 0) {
2268                 $status["attachments"] = $converted["attachments"];
2269         }
2270
2271         if (count($converted["entities"]) > 0) {
2272                 $status["entities"] = $converted["entities"];
2273         }
2274
2275         if ($status["source"] == 'web') {
2276                 $status["source"] = ContactSelector::networkToName($item['author-network'], $item['author-link'], $item['network']);
2277         } elseif (ContactSelector::networkToName($item['author-network'], $item['author-link'], $item['network']) != $status["source"]) {
2278                 $status["source"] = trim($status["source"].' ('.ContactSelector::networkToName($item['author-network'], $item['author-link'], $item['network']).')');
2279         }
2280
2281         $retweeted_item = [];
2282         $quoted_item = [];
2283
2284         if (empty($retweeted_item) && ($item['owner-id'] == $item['author-id'])) {
2285                 $announce = api_get_announce($item);
2286                 if (!empty($announce)) {
2287                         $retweeted_item = $item;
2288                         $item = $announce;
2289                         $status['friendica_owner'] = DI::twitterUser()->createFromContactId($announce['author-id'], BaseApi::getCurrentUserID())->toArray();
2290                 }
2291         }
2292
2293         if (!empty($quoted_item)) {
2294                 if ($quoted_item['id'] != $item['id']) {
2295                         $quoted_status = api_format_item($quoted_item);
2296                         /// @todo Only remove the attachments that are also contained in the quotes status
2297                         unset($status['attachments']);
2298                         unset($status['entities']);
2299                 } else {
2300                         $conv_quoted = api_convert_item($quoted_item);
2301                         $quoted_status = $status;
2302                         unset($quoted_status['attachments']);
2303                         unset($quoted_status['entities']);
2304                         unset($quoted_status['statusnet_conversation_id']);
2305                         $quoted_status['text'] = $conv_quoted['text'];
2306                         $quoted_status['statusnet_html'] = $conv_quoted['html'];
2307                         try {
2308                                 $quoted_status["user"] = DI::twitterUser()->createFromContactId($quoted_item['author-id'], BaseApi::getCurrentUserID())->toArray();
2309                         } catch (BadRequestException $e) {
2310                                 // user not found. should be found?
2311                                 /// @todo check if the user should be always found
2312                                 $quoted_status["user"] = [];
2313                         }
2314                 }
2315                 unset($quoted_status['friendica_author']);
2316                 unset($quoted_status['friendica_owner']);
2317                 unset($quoted_status['friendica_activities']);
2318                 unset($quoted_status['friendica_private']);
2319         }
2320
2321         if (!empty($retweeted_item)) {
2322                 $retweeted_status = $status;
2323                 unset($retweeted_status['friendica_author']);
2324                 unset($retweeted_status['friendica_owner']);
2325                 unset($retweeted_status['friendica_activities']);
2326                 unset($retweeted_status['friendica_private']);
2327                 unset($retweeted_status['statusnet_conversation_id']);
2328                 $status['user'] = $status['friendica_owner'];
2329                 try {
2330                         $retweeted_status["user"] = DI::twitterUser()->createFromContactId($retweeted_item['author-id'], BaseApi::getCurrentUserID())->toArray();
2331                 } catch (BadRequestException $e) {
2332                         // user not found. should be found?
2333                         /// @todo check if the user should be always found
2334                         $retweeted_status["user"] = [];
2335                 }
2336
2337                 $rt_converted = api_convert_item($retweeted_item);
2338
2339                 $retweeted_status['text'] = $rt_converted["text"];
2340                 $retweeted_status['statusnet_html'] = $rt_converted["html"];
2341                 $retweeted_status['friendica_html'] = $rt_converted["html"];
2342                 $retweeted_status['created_at'] =  DateTimeFormat::utc($retweeted_item['created'], DateTimeFormat::API);
2343
2344                 if (!empty($quoted_status)) {
2345                         $retweeted_status['quoted_status'] = $quoted_status;
2346                 }
2347
2348                 $status['friendica_author'] = $retweeted_status['user'];
2349                 $status['retweeted_status'] = $retweeted_status;
2350         } elseif (!empty($quoted_status)) {
2351                 $root_status = api_convert_item($item);
2352
2353                 $status['text'] = $root_status["text"];
2354                 $status['statusnet_html'] = $root_status["html"];
2355                 $status['quoted_status'] = $quoted_status;
2356         }
2357
2358         // "uid" and "self" are only needed for some internal stuff, so remove it from here
2359         unset($status["user"]["uid"]);
2360         unset($status["user"]["self"]);
2361
2362         if ($item["coord"] != "") {
2363                 $coords = explode(' ', $item["coord"]);
2364                 if (count($coords) == 2) {
2365                         if ($type == "json") {
2366                                 $status["geo"] = ['type' => 'Point',
2367                                         'coordinates' => [(float) $coords[0],
2368                                                 (float) $coords[1]]];
2369                         } else {// Not sure if this is the official format - if someone founds a documentation we can check
2370                                 $status["georss:point"] = $item["coord"];
2371                         }
2372                 }
2373         }
2374
2375         return $status;
2376 }
2377
2378 /**
2379  * Returns all lists the user subscribes to.
2380  *
2381  * @param string $type Return type (atom, rss, xml, json)
2382  *
2383  * @return array|string
2384  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-list
2385  */
2386 function api_lists_list($type)
2387 {
2388         $ret = [];
2389         /// @TODO $ret is not filled here?
2390         return DI::apiResponse()->formatData('lists', $type, ["lists_list" => $ret]);
2391 }
2392
2393 /// @TODO move to top of file or somewhere better
2394 api_register_func('api/lists/list', 'api_lists_list', true);
2395 api_register_func('api/lists/subscriptions', 'api_lists_list', true);
2396
2397 /**
2398  * Returns all groups the user owns.
2399  *
2400  * @param string $type Return type (atom, rss, xml, json)
2401  *
2402  * @return array|string
2403  * @throws BadRequestException
2404  * @throws ForbiddenException
2405  * @throws ImagickException
2406  * @throws InternalServerErrorException
2407  * @throws UnauthorizedException
2408  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-ownerships
2409  */
2410 function api_lists_ownerships($type)
2411 {
2412         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2413
2414         // params
2415         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
2416         $uid = $user_info['uid'];
2417
2418         $groups = DBA::select('group', [], ['deleted' => 0, 'uid' => $uid]);
2419
2420         // loop through all groups
2421         $lists = [];
2422         foreach ($groups as $group) {
2423                 if ($group['visible']) {
2424                         $mode = 'public';
2425                 } else {
2426                         $mode = 'private';
2427                 }
2428                 $lists[] = [
2429                         'name' => $group['name'],
2430                         'id' => intval($group['id']),
2431                         'id_str' => (string) $group['id'],
2432                         'user' => $user_info,
2433                         'mode' => $mode
2434                 ];
2435         }
2436         return DI::apiResponse()->formatData("lists", $type, ['lists' => ['lists' => $lists]]);
2437 }
2438
2439 /// @TODO move to top of file or somewhere better
2440 api_register_func('api/lists/ownerships', 'api_lists_ownerships', true);
2441
2442 /**
2443  * Returns recent statuses from users in the specified group.
2444  *
2445  * @param string $type Return type (atom, rss, xml, json)
2446  *
2447  * @return array|string
2448  * @throws BadRequestException
2449  * @throws ForbiddenException
2450  * @throws ImagickException
2451  * @throws InternalServerErrorException
2452  * @throws UnauthorizedException
2453  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-ownerships
2454  */
2455 function api_lists_statuses($type)
2456 {
2457         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2458
2459         unset($_REQUEST['user_id']);
2460         unset($_GET['user_id']);
2461
2462         unset($_REQUEST['screen_name']);
2463         unset($_GET['screen_name']);
2464
2465         if (empty($_REQUEST['list_id'])) {
2466                 throw new BadRequestException('list_id not specified');
2467         }
2468
2469         // params
2470         $count = $_REQUEST['count'] ?? 20;
2471         $page = $_REQUEST['page'] ?? 1;
2472         $since_id = $_REQUEST['since_id'] ?? 0;
2473         $max_id = $_REQUEST['max_id'] ?? 0;
2474         $exclude_replies = (!empty($_REQUEST['exclude_replies']) ? 1 : 0);
2475         $conversation_id = $_REQUEST['conversation_id'] ?? 0;
2476
2477         $start = max(0, ($page - 1) * $count);
2478
2479         $groups = DBA::selectToArray('group_member', ['contact-id'], ['gid' => 1]);
2480         $gids = array_column($groups, 'contact-id');
2481         $condition = ['uid' => BaseApi::getCurrentUserID(), 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], 'group-id' => $gids];
2482         $condition = DBA::mergeConditions($condition, ["`id` > ?", $since_id]);
2483
2484         if ($max_id > 0) {
2485                 $condition[0] .= " AND `id` <= ?";
2486                 $condition[] = $max_id;
2487         }
2488         if ($exclude_replies > 0) {
2489                 $condition[0] .= ' AND `gravity` = ?';
2490                 $condition[] = GRAVITY_PARENT;
2491         }
2492         if ($conversation_id > 0) {
2493                 $condition[0] .= " AND `parent` = ?";
2494                 $condition[] = $conversation_id;
2495         }
2496
2497         $params = ['order' => ['id' => true], 'limit' => [$start, $count]];
2498         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition, $params);
2499
2500         $items = api_format_items(Post::toArray($statuses), $type);
2501
2502         $data = ['status' => $items];
2503         switch ($type) {
2504                 case "atom":
2505                         break;
2506                 case "rss":
2507                         $data = api_rss_extra($data, DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray());
2508                         break;
2509         }
2510
2511         return DI::apiResponse()->formatData("statuses", $type, $data);
2512 }
2513
2514 /// @TODO move to top of file or somewhere better
2515 api_register_func('api/lists/statuses', 'api_lists_statuses', true);
2516
2517 /**
2518  * Returns either the friends of the follower list
2519  *
2520  * Considers friends and followers lists to be private and won't return
2521  * anything if any user_id parameter is passed.
2522  *
2523  * @param string $qtype Either "friends" or "followers"
2524  * @return boolean|array
2525  * @throws BadRequestException
2526  * @throws ForbiddenException
2527  * @throws ImagickException
2528  * @throws InternalServerErrorException
2529  * @throws UnauthorizedException
2530  */
2531 function api_statuses_f($qtype)
2532 {
2533         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2534
2535         // pagination
2536         $count = $_GET['count'] ?? 20;
2537         $page = $_GET['page'] ?? 1;
2538
2539         $start = max(0, ($page - 1) * $count);
2540
2541         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
2542
2543         if (!empty($_GET['cursor']) && $_GET['cursor'] == 'undefined') {
2544                 /* this is to stop Hotot to load friends multiple times
2545                 *  I'm not sure if I'm missing return something or
2546                 *  is a bug in hotot. Workaround, meantime
2547                 */
2548
2549                 /*$ret=Array();
2550                 return array('$users' => $ret);*/
2551                 return false;
2552         }
2553
2554         $sql_extra = '';
2555         if ($qtype == 'friends') {
2556                 $sql_extra = sprintf(" AND ( `rel` = %d OR `rel` = %d ) ", intval(Contact::SHARING), intval(Contact::FRIEND));
2557         } elseif ($qtype == 'followers') {
2558                 $sql_extra = sprintf(" AND ( `rel` = %d OR `rel` = %d ) ", intval(Contact::FOLLOWER), intval(Contact::FRIEND));
2559         }
2560
2561         // friends and followers only for self
2562         if ($user_info['self'] == 0) {
2563                 $sql_extra = " AND false ";
2564         }
2565
2566         if ($qtype == 'blocks') {
2567                 $sql_filter = 'AND `blocked` AND NOT `pending`';
2568         } elseif ($qtype == 'incoming') {
2569                 $sql_filter = 'AND `pending`';
2570         } else {
2571                 $sql_filter = 'AND (NOT `blocked` OR `pending`)';
2572         }
2573
2574         // @todo This query most likely can be replaced with a Contact::select...
2575         $r = DBA::toArray(DBA::p(
2576                 "SELECT `id`
2577                 FROM `contact`
2578                 WHERE `uid` = ?
2579                 AND NOT `self`
2580                 $sql_filter
2581                 $sql_extra
2582                 ORDER BY `nick`
2583                 LIMIT ?, ?",
2584                 BaseApi::getCurrentUserID(),
2585                 $start,
2586                 $count
2587         ));
2588
2589         $ret = [];
2590         foreach ($r as $cid) {
2591                 $user = DI::twitterUser()->createFromContactId($cid['id'], BaseApi::getCurrentUserID())->toArray();
2592                 // "uid" and "self" are only needed for some internal stuff, so remove it from here
2593                 unset($user["uid"]);
2594                 unset($user["self"]);
2595
2596                 if ($user) {
2597                         $ret[] = $user;
2598                 }
2599         }
2600
2601         return ['user' => $ret];
2602 }
2603
2604
2605 /**
2606  * Returns the list of friends of the provided user
2607  *
2608  * @deprecated By Twitter API in favor of friends/list
2609  *
2610  * @param string $type Either "json" or "xml"
2611  * @return boolean|string|array
2612  * @throws BadRequestException
2613  * @throws ForbiddenException
2614  */
2615 function api_statuses_friends($type)
2616 {
2617         $data =  api_statuses_f("friends");
2618         if ($data === false) {
2619                 return false;
2620         }
2621         return DI::apiResponse()->formatData("users", $type, $data);
2622 }
2623
2624 /**
2625  * Returns the list of followers of the provided user
2626  *
2627  * @deprecated By Twitter API in favor of friends/list
2628  *
2629  * @param string $type Either "json" or "xml"
2630  * @return boolean|string|array
2631  * @throws BadRequestException
2632  * @throws ForbiddenException
2633  */
2634 function api_statuses_followers($type)
2635 {
2636         $data = api_statuses_f("followers");
2637         if ($data === false) {
2638                 return false;
2639         }
2640         return DI::apiResponse()->formatData("users", $type, $data);
2641 }
2642
2643 /// @TODO move to top of file or somewhere better
2644 api_register_func('api/statuses/friends', 'api_statuses_friends', true);
2645 api_register_func('api/statuses/followers', 'api_statuses_followers', true);
2646
2647 /**
2648  * Returns the list of blocked users
2649  *
2650  * @see https://developer.twitter.com/en/docs/accounts-and-users/mute-block-report-users/api-reference/get-blocks-list
2651  *
2652  * @param string $type Either "json" or "xml"
2653  *
2654  * @return boolean|string|array
2655  * @throws BadRequestException
2656  * @throws ForbiddenException
2657  */
2658 function api_blocks_list($type)
2659 {
2660         $data =  api_statuses_f('blocks');
2661         if ($data === false) {
2662                 return false;
2663         }
2664         return DI::apiResponse()->formatData("users", $type, $data);
2665 }
2666
2667 /// @TODO move to top of file or somewhere better
2668 api_register_func('api/blocks/list', 'api_blocks_list', true);
2669
2670 /**
2671  * Returns the list of pending users IDs
2672  *
2673  * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-incoming
2674  *
2675  * @param string $type Either "json" or "xml"
2676  *
2677  * @return boolean|string|array
2678  * @throws BadRequestException
2679  * @throws ForbiddenException
2680  */
2681 function api_friendships_incoming($type)
2682 {
2683         $data =  api_statuses_f('incoming');
2684         if ($data === false) {
2685                 return false;
2686         }
2687
2688         $ids = [];
2689         foreach ($data['user'] as $user) {
2690                 $ids[] = $user['id'];
2691         }
2692
2693         return DI::apiResponse()->formatData("ids", $type, ['id' => $ids]);
2694 }
2695
2696 /// @TODO move to top of file or somewhere better
2697 api_register_func('api/friendships/incoming', 'api_friendships_incoming', true);
2698
2699 /**
2700  * Sends a new direct message.
2701  *
2702  * @param string $type Return type (atom, rss, xml, json)
2703  *
2704  * @return array|string
2705  * @throws BadRequestException
2706  * @throws ForbiddenException
2707  * @throws ImagickException
2708  * @throws InternalServerErrorException
2709  * @throws NotFoundException
2710  * @throws UnauthorizedException
2711  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-message
2712  */
2713 function api_direct_messages_new($type)
2714 {
2715         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2716
2717         $uid = BaseApi::getCurrentUserID();
2718
2719         if (empty($_POST["text"]) || empty($_POST['screen_name']) && empty($_POST['user_id'])) {
2720                 return;
2721         }
2722
2723         $sender = DI::twitterUser()->createFromUserId($uid)->toArray();
2724
2725         $cid = BaseApi::getContactIDForSearchterm($_POST['screen_name'] ?? '', $_POST['user_id'] ?? 0, $uid);
2726         if (empty($cid)) {
2727                 throw new NotFoundException('Recipient not found');
2728         }
2729
2730         $replyto = '';
2731         if (!empty($_REQUEST['replyto'])) {
2732                 $mail = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uid' => BaseApi::getCurrentUserID(), 'id' => $_REQUEST['replyto']]);
2733                 $replyto = $mail['parent-uri'];
2734                 $sub     = $mail['title'];
2735         } else {
2736                 if (!empty($_REQUEST['title'])) {
2737                         $sub = $_REQUEST['title'];
2738                 } else {
2739                         $sub = ((strlen($_POST['text'])>10) ? substr($_POST['text'], 0, 10)."...":$_POST['text']);
2740                 }
2741         }
2742
2743         $id = Mail::send($cid, $_POST['text'], $sub, $replyto);
2744
2745         if ($id > -1) {
2746                 $mail = DBA::selectFirst('mail', [], ['id' => $id]);
2747                 $ret = api_format_messages($mail, DI::twitterUser()->createFromContactId($cid, $uid)->toArray(), $sender);
2748         } else {
2749                 $ret = ["error" => $id];
2750         }
2751
2752         $data = ['direct_message'=>$ret];
2753
2754         switch ($type) {
2755                 case "atom":
2756                         break;
2757                 case "rss":
2758                         $data = api_rss_extra($data, $sender);
2759                         break;
2760         }
2761
2762         return DI::apiResponse()->formatData("direct-messages", $type, $data);
2763 }
2764
2765 /// @TODO move to top of file or somewhere better
2766 api_register_func('api/direct_messages/new', 'api_direct_messages_new', true, API_METHOD_POST);
2767
2768 /**
2769  * delete a direct_message from mail table through api
2770  *
2771  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
2772  * @return string|array
2773  * @throws BadRequestException
2774  * @throws ForbiddenException
2775  * @throws ImagickException
2776  * @throws InternalServerErrorException
2777  * @throws UnauthorizedException
2778  * @see   https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/delete-message
2779  */
2780 function api_direct_messages_destroy($type)
2781 {
2782         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2783
2784         // params
2785         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
2786         //required
2787         $id = $_REQUEST['id'] ?? 0;
2788         // optional
2789         $parenturi = $_REQUEST['friendica_parenturi'] ?? '';
2790         $verbose = (!empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false");
2791         /// @todo optional parameter 'include_entities' from Twitter API not yet implemented
2792
2793         $uid = $user_info['uid'];
2794         // error if no id or parenturi specified (for clients posting parent-uri as well)
2795         if ($verbose == "true" && ($id == 0 || $parenturi == "")) {
2796                 $answer = ['result' => 'error', 'message' => 'message id or parenturi not specified'];
2797                 return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
2798         }
2799
2800         // BadRequestException if no id specified (for clients using Twitter API)
2801         if ($id == 0) {
2802                 throw new BadRequestException('Message id not specified');
2803         }
2804
2805         // add parent-uri to sql command if specified by calling app
2806         $sql_extra = ($parenturi != "" ? " AND `parent-uri` = '" . DBA::escape($parenturi) . "'" : "");
2807
2808         // error message if specified id is not in database
2809         if (!DBA::exists('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id])) {
2810                 if ($verbose == "true") {
2811                         $answer = ['result' => 'error', 'message' => 'message id not in database'];
2812                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
2813                 }
2814                 /// @todo BadRequestException ok for Twitter API clients?
2815                 throw new BadRequestException('message id not in database');
2816         }
2817
2818         // delete message
2819         $result = DBA::delete('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id]);
2820
2821         if ($verbose == "true") {
2822                 if ($result) {
2823                         // return success
2824                         $answer = ['result' => 'ok', 'message' => 'message deleted'];
2825                         return DI::apiResponse()->formatData("direct_message_delete", $type, ['$result' => $answer]);
2826                 } else {
2827                         $answer = ['result' => 'error', 'message' => 'unknown error'];
2828                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
2829                 }
2830         }
2831         /// @todo return JSON data like Twitter API not yet implemented
2832 }
2833
2834 /// @TODO move to top of file or somewhere better
2835 api_register_func('api/direct_messages/destroy', 'api_direct_messages_destroy', true, API_METHOD_DELETE);
2836
2837 /**
2838  * Unfollow Contact
2839  *
2840  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
2841  * @return string|array
2842  * @throws HTTPException\BadRequestException
2843  * @throws HTTPException\ExpectationFailedException
2844  * @throws HTTPException\ForbiddenException
2845  * @throws HTTPException\InternalServerErrorException
2846  * @throws HTTPException\NotFoundException
2847  * @see   https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy.html
2848  */
2849 function api_friendships_destroy($type)
2850 {
2851         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2852         $uid = BaseApi::getCurrentUserID();
2853
2854         $owner = User::getOwnerDataById($uid);
2855         if (!$owner) {
2856                 Logger::notice(API_LOG_PREFIX . 'No owner {uid} found', ['module' => 'api', 'action' => 'friendships_destroy', 'uid' => $uid]);
2857                 throw new HTTPException\NotFoundException('Error Processing Request');
2858         }
2859
2860         $contact_id = $_REQUEST['user_id'] ?? 0;
2861
2862         if (empty($contact_id)) {
2863                 Logger::notice(API_LOG_PREFIX . 'No user_id specified', ['module' => 'api', 'action' => 'friendships_destroy']);
2864                 throw new HTTPException\BadRequestException('no user_id specified');
2865         }
2866
2867         // Get Contact by given id
2868         $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => 0, 'self' => false]);
2869
2870         if(!DBA::isResult($contact)) {
2871                 Logger::notice(API_LOG_PREFIX . 'No public contact found for ID {contact}', ['module' => 'api', 'action' => 'friendships_destroy', 'contact' => $contact_id]);
2872                 throw new HTTPException\NotFoundException('no contact found to given ID');
2873         }
2874
2875         $url = $contact['url'];
2876
2877         $condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)",
2878                         $uid, Contact::SHARING, Contact::FRIEND, Strings::normaliseLink($url),
2879                         Strings::normaliseLink($url), $url];
2880         $contact = DBA::selectFirst('contact', [], $condition);
2881
2882         if (!DBA::isResult($contact)) {
2883                 Logger::notice(API_LOG_PREFIX . 'Not following contact', ['module' => 'api', 'action' => 'friendships_destroy']);
2884                 throw new HTTPException\NotFoundException('Not following Contact');
2885         }
2886
2887         try {
2888                 $result = Contact::terminateFriendship($owner, $contact);
2889
2890                 if ($result === null) {
2891                         Logger::notice(API_LOG_PREFIX . 'Not supported for {network}', ['module' => 'api', 'action' => 'friendships_destroy', 'network' => $contact['network']]);
2892                         throw new HTTPException\ExpectationFailedException('Unfollowing is currently not supported by this contact\'s network.');
2893                 }
2894
2895                 if ($result === false) {
2896                         throw new HTTPException\ServiceUnavailableException('Unable to unfollow this contact, please retry in a few minutes or contact your administrator.');
2897                 }
2898         } catch (Exception $e) {
2899                 Logger::error(API_LOG_PREFIX . $e->getMessage(), ['owner' => $owner, 'contact' => $contact]);
2900                 throw new HTTPException\InternalServerErrorException('Unable to unfollow this contact, please contact your administrator');
2901         }
2902
2903         // "uid" and "self" are only needed for some internal stuff, so remove it from here
2904         unset($contact['uid']);
2905         unset($contact['self']);
2906
2907         // Set screen_name since Twidere requests it
2908         $contact['screen_name'] = $contact['nick'];
2909
2910         return DI::apiResponse()->formatData('friendships-destroy', $type, ['user' => $contact]);
2911 }
2912
2913 api_register_func('api/friendships/destroy', 'api_friendships_destroy', true, API_METHOD_POST);
2914
2915 /**
2916  *
2917  * @param string $type Return type (atom, rss, xml, json)
2918  * @param string $box
2919  * @param string $verbose
2920  *
2921  * @return array|string
2922  * @throws BadRequestException
2923  * @throws ForbiddenException
2924  * @throws ImagickException
2925  * @throws InternalServerErrorException
2926  * @throws UnauthorizedException
2927  */
2928 function api_direct_messages_box($type, $box, $verbose)
2929 {
2930         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2931
2932         // params
2933         $count = $_GET['count'] ?? 20;
2934         $page = $_REQUEST['page'] ?? 1;
2935
2936         $since_id = $_REQUEST['since_id'] ?? 0;
2937         $max_id = $_REQUEST['max_id'] ?? 0;
2938
2939         $user_id = $_REQUEST['user_id'] ?? '';
2940         $screen_name = $_REQUEST['screen_name'] ?? '';
2941
2942         //  caller user info
2943         unset($_REQUEST['user_id']);
2944         unset($_GET['user_id']);
2945
2946         unset($_REQUEST['screen_name']);
2947         unset($_GET['screen_name']);
2948
2949         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
2950
2951         $profile_url = $user_info["url"];
2952
2953         // pagination
2954         $start = max(0, ($page - 1) * $count);
2955
2956         $sql_extra = "";
2957
2958         // filters
2959         if ($box=="sentbox") {
2960                 $sql_extra = "`mail`.`from-url`='" . DBA::escape($profile_url) . "'";
2961         } elseif ($box == "conversation") {
2962                 $sql_extra = "`mail`.`parent-uri`='" . DBA::escape($_GET['uri'] ?? '')  . "'";
2963         } elseif ($box == "all") {
2964                 $sql_extra = "true";
2965         } elseif ($box == "inbox") {
2966                 $sql_extra = "`mail`.`from-url`!='" . DBA::escape($profile_url) . "'";
2967         }
2968
2969         if ($max_id > 0) {
2970                 $sql_extra .= ' AND `mail`.`id` <= ' . intval($max_id);
2971         }
2972
2973         if ($user_id != "") {
2974                 $sql_extra .= ' AND `mail`.`contact-id` = ' . intval($user_id);
2975         } elseif ($screen_name !="") {
2976                 $sql_extra .= " AND `contact`.`nick` = '" . DBA::escape($screen_name). "'";
2977         }
2978
2979         $r = DBA::toArray(DBA::p(
2980                 "SELECT `mail`.*, `contact`.`nurl` AS `contact-url` FROM `mail`,`contact` WHERE `mail`.`contact-id` = `contact`.`id` AND `mail`.`uid` = ? AND $sql_extra AND `mail`.`id` > ? ORDER BY `mail`.`id` DESC LIMIT ?,?",
2981                 BaseApi::getCurrentUserID(),
2982                 $since_id,
2983                 $start,
2984                 $count
2985         ));
2986         if ($verbose == "true" && !DBA::isResult($r)) {
2987                 $answer = ['result' => 'error', 'message' => 'no mails available'];
2988                 return DI::apiResponse()->formatData("direct_messages_all", $type, ['$result' => $answer]);
2989         }
2990
2991         $ret = [];
2992         foreach ($r as $item) {
2993                 if ($box == "inbox" || $item['from-url'] != $profile_url) {
2994                         $recipient = $user_info;
2995                         $sender = DI::twitterUser()->createFromContactId($item['contact-id'], BaseApi::getCurrentUserID())->toArray();
2996                 } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
2997                         $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], BaseApi::getCurrentUserID())->toArray();
2998                         $sender = $user_info;
2999                 }
3000
3001                 if (isset($recipient) && isset($sender)) {
3002                         $ret[] = api_format_messages($item, $recipient, $sender);
3003                 }
3004         }
3005
3006
3007         $data = ['direct_message' => $ret];
3008         switch ($type) {
3009                 case "atom":
3010                         break;
3011                 case "rss":
3012                         $data = api_rss_extra($data, $user_info);
3013                         break;
3014         }
3015
3016         return DI::apiResponse()->formatData("direct-messages", $type, $data);
3017 }
3018
3019 /**
3020  * Returns the most recent direct messages sent by the user.
3021  *
3022  * @param string $type Return type (atom, rss, xml, json)
3023  *
3024  * @return array|string
3025  * @throws BadRequestException
3026  * @throws ForbiddenException
3027  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-sent-message
3028  */
3029 function api_direct_messages_sentbox($type)
3030 {
3031         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
3032         return api_direct_messages_box($type, "sentbox", $verbose);
3033 }
3034
3035 /**
3036  * Returns the most recent direct messages sent to the user.
3037  *
3038  * @param string $type Return type (atom, rss, xml, json)
3039  *
3040  * @return array|string
3041  * @throws BadRequestException
3042  * @throws ForbiddenException
3043  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-messages
3044  */
3045 function api_direct_messages_inbox($type)
3046 {
3047         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
3048         return api_direct_messages_box($type, "inbox", $verbose);
3049 }
3050
3051 /**
3052  *
3053  * @param string $type Return type (atom, rss, xml, json)
3054  *
3055  * @return array|string
3056  * @throws BadRequestException
3057  * @throws ForbiddenException
3058  */
3059 function api_direct_messages_all($type)
3060 {
3061         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
3062         return api_direct_messages_box($type, "all", $verbose);
3063 }
3064
3065 /**
3066  *
3067  * @param string $type Return type (atom, rss, xml, json)
3068  *
3069  * @return array|string
3070  * @throws BadRequestException
3071  * @throws ForbiddenException
3072  */
3073 function api_direct_messages_conversation($type)
3074 {
3075         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
3076         return api_direct_messages_box($type, "conversation", $verbose);
3077 }
3078
3079 /// @TODO move to top of file or somewhere better
3080 api_register_func('api/direct_messages/conversation', 'api_direct_messages_conversation', true);
3081 api_register_func('api/direct_messages/all', 'api_direct_messages_all', true);
3082 api_register_func('api/direct_messages/sent', 'api_direct_messages_sentbox', true);
3083 api_register_func('api/direct_messages', 'api_direct_messages_inbox', true);
3084
3085 /**
3086  * list all photos of the authenticated user
3087  *
3088  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
3089  * @return string|array
3090  * @throws ForbiddenException
3091  * @throws InternalServerErrorException
3092  */
3093 function api_fr_photos_list($type)
3094 {
3095         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
3096
3097         $r = DBA::toArray(DBA::p(
3098                 "SELECT `resource-id`, MAX(scale) AS `scale`, `album`, `filename`, `type`, MAX(`created`) AS `created`,
3099                 MAX(`edited`) AS `edited`, MAX(`desc`) AS `desc` FROM `photo`
3100                 WHERE `uid` = ? AND NOT `photo-type` IN (?, ?) GROUP BY `resource-id`, `album`, `filename`, `type`",
3101                 BaseApi::getCurrentUserID(), Photo::CONTACT_AVATAR, Photo::CONTACT_BANNER
3102         ));
3103         $typetoext = [
3104                 'image/jpeg' => 'jpg',
3105                 'image/png' => 'png',
3106                 'image/gif' => 'gif'
3107         ];
3108         $data = ['photo'=>[]];
3109         if (DBA::isResult($r)) {
3110                 foreach ($r as $rr) {
3111                         $photo = [];
3112                         $photo['id'] = $rr['resource-id'];
3113                         $photo['album'] = $rr['album'];
3114                         $photo['filename'] = $rr['filename'];
3115                         $photo['type'] = $rr['type'];
3116                         $thumb = DI::baseUrl() . "/photo/" . $rr['resource-id'] . "-" . $rr['scale'] . "." . $typetoext[$rr['type']];
3117                         $photo['created'] = $rr['created'];
3118                         $photo['edited'] = $rr['edited'];
3119                         $photo['desc'] = $rr['desc'];
3120
3121                         if ($type == "xml") {
3122                                 $data['photo'][] = ["@attributes" => $photo, "1" => $thumb];
3123                         } else {
3124                                 $photo['thumb'] = $thumb;
3125                                 $data['photo'][] = $photo;
3126                         }
3127                 }
3128         }
3129         return DI::apiResponse()->formatData("photos", $type, $data);
3130 }
3131
3132 /**
3133  * upload a new photo or change an existing photo
3134  *
3135  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
3136  * @return string|array
3137  * @throws BadRequestException
3138  * @throws ForbiddenException
3139  * @throws ImagickException
3140  * @throws InternalServerErrorException
3141  * @throws NotFoundException
3142  */
3143 function api_fr_photo_create_update($type)
3144 {
3145         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
3146
3147         // input params
3148         $photo_id  = $_REQUEST['photo_id']  ?? null;
3149         $desc      = $_REQUEST['desc']      ?? null;
3150         $album     = $_REQUEST['album']     ?? null;
3151         $album_new = $_REQUEST['album_new'] ?? null;
3152         $allow_cid = $_REQUEST['allow_cid'] ?? null;
3153         $deny_cid  = $_REQUEST['deny_cid' ] ?? null;
3154         $allow_gid = $_REQUEST['allow_gid'] ?? null;
3155         $deny_gid  = $_REQUEST['deny_gid' ] ?? null;
3156         $visibility = !$allow_cid && !$deny_cid && !$allow_gid && !$deny_gid;
3157
3158         // do several checks on input parameters
3159         // we do not allow calls without album string
3160         if ($album == null) {
3161                 throw new BadRequestException("no albumname specified");
3162         }
3163         // if photo_id == null --> we are uploading a new photo
3164         if ($photo_id == null) {
3165                 $mode = "create";
3166
3167                 // error if no media posted in create-mode
3168                 if (empty($_FILES['media'])) {
3169                         // Output error
3170                         throw new BadRequestException("no media data submitted");
3171                 }
3172
3173                 // album_new will be ignored in create-mode
3174                 $album_new = "";
3175         } else {
3176                 $mode = "update";
3177
3178                 // check if photo is existing in databasei
3179                 if (!Photo::exists(['resource-id' => $photo_id, 'uid' => BaseApi::getCurrentUserID(), 'album' => $album])) {
3180                         throw new BadRequestException("photo not available");
3181                 }
3182         }
3183
3184         // checks on acl strings provided by clients
3185         $acl_input_error = false;
3186         $acl_input_error |= check_acl_input($allow_cid);
3187         $acl_input_error |= check_acl_input($deny_cid);
3188         $acl_input_error |= check_acl_input($allow_gid);
3189         $acl_input_error |= check_acl_input($deny_gid);
3190         if ($acl_input_error) {
3191                 throw new BadRequestException("acl data invalid");
3192         }
3193         // now let's upload the new media in create-mode
3194         if ($mode == "create") {
3195                 $media = $_FILES['media'];
3196                 $data = save_media_to_database("photo", $media, $type, $album, trim($allow_cid), trim($deny_cid), trim($allow_gid), trim($deny_gid), $desc, Photo::DEFAULT, $visibility);
3197
3198                 // return success of updating or error message
3199                 if (!is_null($data)) {
3200                         return DI::apiResponse()->formatData("photo_create", $type, $data);
3201                 } else {
3202                         throw new InternalServerErrorException("unknown error - uploading photo failed, see Friendica log for more information");
3203                 }
3204         }
3205
3206         // now let's do the changes in update-mode
3207         if ($mode == "update") {
3208                 $updated_fields = [];
3209
3210                 if (!is_null($desc)) {
3211                         $updated_fields['desc'] = $desc;
3212                 }
3213
3214                 if (!is_null($album_new)) {
3215                         $updated_fields['album'] = $album_new;
3216                 }
3217
3218                 if (!is_null($allow_cid)) {
3219                         $allow_cid = trim($allow_cid);
3220                         $updated_fields['allow_cid'] = $allow_cid;
3221                 }
3222
3223                 if (!is_null($deny_cid)) {
3224                         $deny_cid = trim($deny_cid);
3225                         $updated_fields['deny_cid'] = $deny_cid;
3226                 }
3227
3228                 if (!is_null($allow_gid)) {
3229                         $allow_gid = trim($allow_gid);
3230                         $updated_fields['allow_gid'] = $allow_gid;
3231                 }
3232
3233                 if (!is_null($deny_gid)) {
3234                         $deny_gid = trim($deny_gid);
3235                         $updated_fields['deny_gid'] = $deny_gid;
3236                 }
3237
3238                 $result = false;
3239                 if (count($updated_fields) > 0) {
3240                         $nothingtodo = false;
3241                         $result = Photo::update($updated_fields, ['uid' => BaseApi::getCurrentUserID(), 'resource-id' => $photo_id, 'album' => $album]);
3242                 } else {
3243                         $nothingtodo = true;
3244                 }
3245
3246                 if (!empty($_FILES['media'])) {
3247                         $nothingtodo = false;
3248                         $media = $_FILES['media'];
3249                         $data = save_media_to_database("photo", $media, $type, $album, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $desc, Photo::DEFAULT, $visibility, $photo_id);
3250                         if (!is_null($data)) {
3251                                 return DI::apiResponse()->formatData("photo_update", $type, $data);
3252                         }
3253                 }
3254
3255                 // return success of updating or error message
3256                 if ($result) {
3257                         $answer = ['result' => 'updated', 'message' => 'Image id `' . $photo_id . '` has been updated.'];
3258                         return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
3259                 } else {
3260                         if ($nothingtodo) {
3261                                 $answer = ['result' => 'cancelled', 'message' => 'Nothing to update for image id `' . $photo_id . '`.'];
3262                                 return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
3263                         }
3264                         throw new InternalServerErrorException("unknown error - update photo entry in database failed");
3265                 }
3266         }
3267         throw new InternalServerErrorException("unknown error - this error on uploading or updating a photo should never happen");
3268 }
3269
3270 /**
3271  * returns the details of a specified photo id, if scale is given, returns the photo data in base 64
3272  *
3273  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
3274  * @return string|array
3275  * @throws BadRequestException
3276  * @throws ForbiddenException
3277  * @throws InternalServerErrorException
3278  * @throws NotFoundException
3279  */
3280 function api_fr_photo_detail($type)
3281 {
3282         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
3283
3284         if (empty($_REQUEST['photo_id'])) {
3285                 throw new BadRequestException("No photo id.");
3286         }
3287
3288         $scale = (!empty($_REQUEST['scale']) ? intval($_REQUEST['scale']) : false);
3289         $photo_id = $_REQUEST['photo_id'];
3290
3291         // prepare json/xml output with data from database for the requested photo
3292         $data = prepare_photo_data($type, $scale, $photo_id);
3293
3294         return DI::apiResponse()->formatData("photo_detail", $type, $data);
3295 }
3296
3297
3298 /**
3299  * updates the profile image for the user (either a specified profile or the default profile)
3300  *
3301  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
3302  *
3303  * @return string|array
3304  * @throws BadRequestException
3305  * @throws ForbiddenException
3306  * @throws ImagickException
3307  * @throws InternalServerErrorException
3308  * @throws NotFoundException
3309  * @see   https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image
3310  */
3311 function api_account_update_profile_image($type)
3312 {
3313         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
3314
3315         // input params
3316         $profile_id = $_REQUEST['profile_id'] ?? 0;
3317
3318         // error if image data is missing
3319         if (empty($_FILES['image'])) {
3320                 throw new BadRequestException("no media data submitted");
3321         }
3322
3323         // check if specified profile id is valid
3324         if ($profile_id != 0) {
3325                 $profile = DBA::selectFirst('profile', ['is-default'], ['uid' => BaseApi::getCurrentUserID(), 'id' => $profile_id]);
3326                 // error message if specified profile id is not in database
3327                 if (!DBA::isResult($profile)) {
3328                         throw new BadRequestException("profile_id not available");
3329                 }
3330                 $is_default_profile = $profile['is-default'];
3331         } else {
3332                 $is_default_profile = 1;
3333         }
3334
3335         // get mediadata from image or media (Twitter call api/account/update_profile_image provides image)
3336         $media = null;
3337         if (!empty($_FILES['image'])) {
3338                 $media = $_FILES['image'];
3339         } elseif (!empty($_FILES['media'])) {
3340                 $media = $_FILES['media'];
3341         }
3342         // save new profile image
3343         $data = save_media_to_database("profileimage", $media, $type, DI::l10n()->t(Photo::PROFILE_PHOTOS), "", "", "", "", "", Photo::USER_AVATAR);
3344
3345         // get filetype
3346         if (is_array($media['type'])) {
3347                 $filetype = $media['type'][0];
3348         } else {
3349                 $filetype = $media['type'];
3350         }
3351         if ($filetype == "image/jpeg") {
3352                 $fileext = "jpg";
3353         } elseif ($filetype == "image/png") {
3354                 $fileext = "png";
3355         } else {
3356                 throw new InternalServerErrorException('Unsupported filetype');
3357         }
3358
3359         // change specified profile or all profiles to the new resource-id
3360         if ($is_default_profile) {
3361                 $condition = ["`profile` AND `resource-id` != ? AND `uid` = ?", $data['photo']['id'], BaseApi::getCurrentUserID()];
3362                 Photo::update(['profile' => false, 'photo-type' => Photo::DEFAULT], $condition);
3363         } else {
3364                 $fields = ['photo' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-4.' . $fileext,
3365                         'thumb' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-5.' . $fileext];
3366                 DBA::update('profile', $fields, ['id' => $_REQUEST['profile'], 'uid' => BaseApi::getCurrentUserID()]);
3367         }
3368
3369         Contact::updateSelfFromUserID(BaseApi::getCurrentUserID(), true);
3370
3371         // Update global directory in background
3372         Profile::publishUpdate(BaseApi::getCurrentUserID());
3373
3374         // output for client
3375         if ($data) {
3376                 return api_account_verify_credentials($type);
3377         } else {
3378                 // SaveMediaToDatabase failed for some reason
3379                 throw new InternalServerErrorException("image upload failed");
3380         }
3381 }
3382
3383 // place api-register for photoalbum calls before 'api/friendica/photo', otherwise this function is never reached
3384 api_register_func('api/friendica/photos/list', 'api_fr_photos_list', true);
3385 api_register_func('api/friendica/photo/create', 'api_fr_photo_create_update', true, API_METHOD_POST);
3386 api_register_func('api/friendica/photo/update', 'api_fr_photo_create_update', true, API_METHOD_POST);
3387 api_register_func('api/friendica/photo', 'api_fr_photo_detail', true);
3388 api_register_func('api/account/update_profile_image', 'api_account_update_profile_image', true, API_METHOD_POST);
3389
3390 /**
3391  * Update user profile
3392  *
3393  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
3394  *
3395  * @return array|string
3396  * @throws BadRequestException
3397  * @throws ForbiddenException
3398  * @throws ImagickException
3399  * @throws InternalServerErrorException
3400  * @throws UnauthorizedException
3401  */
3402 function api_account_update_profile($type)
3403 {
3404         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
3405
3406         $local_user = BaseApi::getCurrentUserID();
3407
3408         $api_user = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
3409
3410         if (!empty($_POST['name'])) {
3411                 DBA::update('profile', ['name' => $_POST['name']], ['uid' => $local_user]);
3412                 DBA::update('user', ['username' => $_POST['name']], ['uid' => $local_user]);
3413                 Contact::update(['name' => $_POST['name']], ['uid' => $local_user, 'self' => 1]);
3414                 Contact::update(['name' => $_POST['name']], ['id' => $api_user['id']]);
3415         }
3416
3417         if (isset($_POST['description'])) {
3418                 DBA::update('profile', ['about' => $_POST['description']], ['uid' => $local_user]);
3419                 Contact::update(['about' => $_POST['description']], ['uid' => $local_user, 'self' => 1]);
3420                 Contact::update(['about' => $_POST['description']], ['id' => $api_user['id']]);
3421         }
3422
3423         Profile::publishUpdate($local_user);
3424
3425         return api_account_verify_credentials($type);
3426 }
3427
3428 /// @TODO move to top of file or somewhere better
3429 api_register_func('api/account/update_profile', 'api_account_update_profile', true, API_METHOD_POST);
3430
3431 /**
3432  *
3433  * @param string $acl_string
3434  * @return bool
3435  * @throws Exception
3436  */
3437 function check_acl_input($acl_string)
3438 {
3439         if (empty($acl_string)) {
3440                 return false;
3441         }
3442
3443         $contact_not_found = false;
3444
3445         // split <x><y><z> into array of cid's
3446         preg_match_all("/<[A-Za-z0-9]+>/", $acl_string, $array);
3447
3448         // check for each cid if it is available on server
3449         $cid_array = $array[0];
3450         foreach ($cid_array as $cid) {
3451                 $cid = str_replace("<", "", $cid);
3452                 $cid = str_replace(">", "", $cid);
3453                 $condition = ['id' => $cid, 'uid' => BaseApi::getCurrentUserID()];
3454                 $contact_not_found |= !DBA::exists('contact', $condition);
3455         }
3456         return $contact_not_found;
3457 }
3458
3459 /**
3460  * @param string  $mediatype
3461  * @param array   $media
3462  * @param string  $type
3463  * @param string  $album
3464  * @param string  $allow_cid
3465  * @param string  $deny_cid
3466  * @param string  $allow_gid
3467  * @param string  $deny_gid
3468  * @param string  $desc
3469  * @param integer $phototype
3470  * @param boolean $visibility
3471  * @param string  $photo_id
3472  * @return array
3473  * @throws BadRequestException
3474  * @throws ForbiddenException
3475  * @throws ImagickException
3476  * @throws InternalServerErrorException
3477  * @throws NotFoundException
3478  * @throws UnauthorizedException
3479  */
3480 function save_media_to_database($mediatype, $media, $type, $album, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $desc, $phototype = 0, $visibility = false, $photo_id = null)
3481 {
3482         $visitor   = 0;
3483         $src = "";
3484         $filetype = "";
3485         $filename = "";
3486         $filesize = 0;
3487
3488         if (is_array($media)) {
3489                 if (is_array($media['tmp_name'])) {
3490                         $src = $media['tmp_name'][0];
3491                 } else {
3492                         $src = $media['tmp_name'];
3493                 }
3494                 if (is_array($media['name'])) {
3495                         $filename = basename($media['name'][0]);
3496                 } else {
3497                         $filename = basename($media['name']);
3498                 }
3499                 if (is_array($media['size'])) {
3500                         $filesize = intval($media['size'][0]);
3501                 } else {
3502                         $filesize = intval($media['size']);
3503                 }
3504                 if (is_array($media['type'])) {
3505                         $filetype = $media['type'][0];
3506                 } else {
3507                         $filetype = $media['type'];
3508                 }
3509         }
3510
3511         $filetype = Images::getMimeTypeBySource($src, $filename, $filetype);
3512
3513         logger::info(
3514                 "File upload src: " . $src . " - filename: " . $filename .
3515                 " - size: " . $filesize . " - type: " . $filetype);
3516
3517         // check if there was a php upload error
3518         if ($filesize == 0 && $media['error'] == 1) {
3519                 throw new InternalServerErrorException("image size exceeds PHP config settings, file was rejected by server");
3520         }
3521         // check against max upload size within Friendica instance
3522         $maximagesize = DI::config()->get('system', 'maximagesize');
3523         if ($maximagesize && ($filesize > $maximagesize)) {
3524                 $formattedBytes = Strings::formatBytes($maximagesize);
3525                 throw new InternalServerErrorException("image size exceeds Friendica config setting (uploaded size: $formattedBytes)");
3526         }
3527
3528         // create Photo instance with the data of the image
3529         $imagedata = @file_get_contents($src);
3530         $Image = new Image($imagedata, $filetype);
3531         if (!$Image->isValid()) {
3532                 throw new InternalServerErrorException("unable to process image data");
3533         }
3534
3535         // check orientation of image
3536         $Image->orient($src);
3537         @unlink($src);
3538
3539         // check max length of images on server
3540         $max_length = DI::config()->get('system', 'max_image_length');
3541         if ($max_length > 0) {
3542                 $Image->scaleDown($max_length);
3543                 logger::info("File upload: Scaling picture to new size " . $max_length);
3544         }
3545         $width = $Image->getWidth();
3546         $height = $Image->getHeight();
3547
3548         // create a new resource-id if not already provided
3549         $resource_id = ($photo_id == null) ? Photo::newResource() : $photo_id;
3550
3551         if ($mediatype == "photo") {
3552                 // upload normal image (scales 0, 1, 2)
3553                 logger::info("photo upload: starting new photo upload");
3554
3555                 $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 0, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3556                 if (!$r) {
3557                         logger::notice("photo upload: image upload with scale 0 (original size) failed");
3558                 }
3559                 if ($width > 640 || $height > 640) {
3560                         $Image->scaleDown(640);
3561                         $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 1, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3562                         if (!$r) {
3563                                 logger::notice("photo upload: image upload with scale 1 (640x640) failed");
3564                         }
3565                 }
3566
3567                 if ($width > 320 || $height > 320) {
3568                         $Image->scaleDown(320);
3569                         $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 2, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3570                         if (!$r) {
3571                                 logger::notice("photo upload: image upload with scale 2 (320x320) failed");
3572                         }
3573                 }
3574                 logger::info("photo upload: new photo upload ended");
3575         } elseif ($mediatype == "profileimage") {
3576                 // upload profile image (scales 4, 5, 6)
3577                 logger::info("photo upload: starting new profile image upload");
3578
3579                 if ($width > 300 || $height > 300) {
3580                         $Image->scaleDown(300);
3581                         $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 4, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3582                         if (!$r) {
3583                                 logger::notice("photo upload: profile image upload with scale 4 (300x300) failed");
3584                         }
3585                 }
3586
3587                 if ($width > 80 || $height > 80) {
3588                         $Image->scaleDown(80);
3589                         $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 5, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3590                         if (!$r) {
3591                                 logger::notice("photo upload: profile image upload with scale 5 (80x80) failed");
3592                         }
3593                 }
3594
3595                 if ($width > 48 || $height > 48) {
3596                         $Image->scaleDown(48);
3597                         $r = Photo::store($Image, BaseApi::getCurrentUserID(), $visitor, $resource_id, $filename, $album, 6, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
3598                         if (!$r) {
3599                                 logger::notice("photo upload: profile image upload with scale 6 (48x48) failed");
3600                         }
3601                 }
3602                 $Image->__destruct();
3603                 logger::info("photo upload: new profile image upload ended");
3604         }
3605
3606         if (!empty($r)) {
3607                 // create entry in 'item'-table on new uploads to enable users to comment/like/dislike the photo
3608                 if ($photo_id == null && $mediatype == "photo") {
3609                         post_photo_item($resource_id, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility);
3610                 }
3611                 // on success return image data in json/xml format (like /api/friendica/photo does when no scale is given)
3612                 return prepare_photo_data($type, false, $resource_id);
3613         } else {
3614                 throw new InternalServerErrorException("image upload failed");
3615         }
3616 }
3617
3618 /**
3619  *
3620  * @param string  $hash
3621  * @param string  $allow_cid
3622  * @param string  $deny_cid
3623  * @param string  $allow_gid
3624  * @param string  $deny_gid
3625  * @param string  $filetype
3626  * @param boolean $visibility
3627  * @throws InternalServerErrorException
3628  */
3629 function post_photo_item($hash, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility = false)
3630 {
3631         // get data about the api authenticated user
3632         $uri = Item::newURI(intval(BaseApi::getCurrentUserID()));
3633         $owner_record = DBA::selectFirst('contact', [], ['uid' => BaseApi::getCurrentUserID(), 'self' => true]);
3634
3635         $arr = [];
3636         $arr['guid']          = System::createUUID();
3637         $arr['uid']           = intval(BaseApi::getCurrentUserID());
3638         $arr['uri']           = $uri;
3639         $arr['type']          = 'photo';
3640         $arr['wall']          = 1;
3641         $arr['resource-id']   = $hash;
3642         $arr['contact-id']    = $owner_record['id'];
3643         $arr['owner-name']    = $owner_record['name'];
3644         $arr['owner-link']    = $owner_record['url'];
3645         $arr['owner-avatar']  = $owner_record['thumb'];
3646         $arr['author-name']   = $owner_record['name'];
3647         $arr['author-link']   = $owner_record['url'];
3648         $arr['author-avatar'] = $owner_record['thumb'];
3649         $arr['title']         = "";
3650         $arr['allow_cid']     = $allow_cid;
3651         $arr['allow_gid']     = $allow_gid;
3652         $arr['deny_cid']      = $deny_cid;
3653         $arr['deny_gid']      = $deny_gid;
3654         $arr['visible']       = $visibility;
3655         $arr['origin']        = 1;
3656
3657         $typetoext = [
3658                         'image/jpeg' => 'jpg',
3659                         'image/png' => 'png',
3660                         'image/gif' => 'gif'
3661                         ];
3662
3663         // adds link to the thumbnail scale photo
3664         $arr['body'] = '[url=' . DI::baseUrl() . '/photos/' . $owner_record['nick'] . '/image/' . $hash . ']'
3665                                 . '[img]' . DI::baseUrl() . '/photo/' . $hash . '-' . "2" . '.'. $typetoext[$filetype] . '[/img]'
3666                                 . '[/url]';
3667
3668         // do the magic for storing the item in the database and trigger the federation to other contacts
3669         Item::insert($arr);
3670 }
3671
3672 /**
3673  *
3674  * @param string $type
3675  * @param int    $scale
3676  * @param string $photo_id
3677  *
3678  * @return array
3679  * @throws BadRequestException
3680  * @throws ForbiddenException
3681  * @throws ImagickException
3682  * @throws InternalServerErrorException
3683  * @throws NotFoundException
3684  * @throws UnauthorizedException
3685  */
3686 function prepare_photo_data($type, $scale, $photo_id)
3687 {
3688         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
3689
3690         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
3691
3692         $scale_sql = ($scale === false ? "" : sprintf("AND scale=%d", intval($scale)));
3693         $data_sql = ($scale === false ? "" : "data, ");
3694
3695         // added allow_cid, allow_gid, deny_cid, deny_gid to output as string like stored in database
3696         // clients needs to convert this in their way for further processing
3697         $r = DBA::toArray(DBA::p(
3698                 "SELECT $data_sql `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
3699                                         `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`,
3700                                         MIN(`scale`) AS `minscale`, MAX(`scale`) AS `maxscale`
3701                         FROM `photo` WHERE `uid` = ? AND `resource-id` = ? $scale_sql GROUP BY
3702                                    `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
3703                                    `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`",
3704                 BaseApi::getCurrentUserID(),
3705                 $photo_id
3706         ));
3707
3708         $typetoext = [
3709                 'image/jpeg' => 'jpg',
3710                 'image/png' => 'png',
3711                 'image/gif' => 'gif'
3712         ];
3713
3714         // prepare output data for photo
3715         if (DBA::isResult($r)) {
3716                 $data = ['photo' => $r[0]];
3717                 $data['photo']['id'] = $data['photo']['resource-id'];
3718                 if ($scale !== false) {
3719                         $data['photo']['data'] = base64_encode($data['photo']['data']);
3720                 } else {
3721                         unset($data['photo']['datasize']); //needed only with scale param
3722                 }
3723                 if ($type == "xml") {
3724                         $data['photo']['links'] = [];
3725                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
3726                                 $data['photo']['links'][$k . ":link"]["@attributes"] = ["type" => $data['photo']['type'],
3727                                                                                 "scale" => $k,
3728                                                                                 "href" => DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']]];
3729                         }
3730                 } else {
3731                         $data['photo']['link'] = [];
3732                         // when we have profile images we could have only scales from 4 to 6, but index of array always needs to start with 0
3733                         $i = 0;
3734                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
3735                                 $data['photo']['link'][$i] = DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']];
3736                                 $i++;
3737                         }
3738                 }
3739                 unset($data['photo']['resource-id']);
3740                 unset($data['photo']['minscale']);
3741                 unset($data['photo']['maxscale']);
3742         } else {
3743                 throw new NotFoundException();
3744         }
3745
3746         // retrieve item element for getting activities (like, dislike etc.) related to photo
3747         $condition = ['uid' => BaseApi::getCurrentUserID(), 'resource-id' => $photo_id];
3748         $item = Post::selectFirst(['id', 'uid', 'uri', 'parent', 'allow_cid', 'deny_cid', 'allow_gid', 'deny_gid'], $condition);
3749         if (!DBA::isResult($item)) {
3750                 throw new NotFoundException('Photo-related item not found.');
3751         }
3752
3753         $data['photo']['friendica_activities'] = api_format_items_activities($item, $type);
3754
3755         // retrieve comments on photo
3756         $condition = ["`parent` = ? AND `uid` = ? AND `gravity` IN (?, ?)",
3757                 $item['parent'], BaseApi::getCurrentUserID(), GRAVITY_PARENT, GRAVITY_COMMENT];
3758
3759         $statuses = Post::selectForUser(BaseApi::getCurrentUserID(), [], $condition);
3760
3761         // prepare output of comments
3762         $commentData = api_format_items(Post::toArray($statuses), $type);
3763         $comments = [];
3764         if ($type == "xml") {
3765                 $k = 0;
3766                 foreach ($commentData as $comment) {
3767                         $comments[$k++ . ":comment"] = $comment;
3768                 }
3769         } else {
3770                 foreach ($commentData as $comment) {
3771                         $comments[] = $comment;
3772                 }
3773         }
3774         $data['photo']['friendica_comments'] = $comments;
3775
3776         // include info if rights on photo and rights on item are mismatching
3777         $rights_mismatch = $data['photo']['allow_cid'] != $item['allow_cid'] ||
3778                 $data['photo']['deny_cid'] != $item['deny_cid'] ||
3779                 $data['photo']['allow_gid'] != $item['allow_gid'] ||
3780                 $data['photo']['deny_gid'] != $item['deny_gid'];
3781         $data['photo']['rights_mismatch'] = $rights_mismatch;
3782
3783         return $data;
3784 }
3785
3786 /**
3787  * Return an item with announcer data if it had been announced
3788  *
3789  * @param array $item Item array
3790  * @return array Item array with announce data
3791  */
3792 function api_get_announce($item)
3793 {
3794         // Quit if the item already has got a different owner and author
3795         if ($item['owner-id'] != $item['author-id']) {
3796                 return [];
3797         }
3798
3799         // Don't change original or Diaspora posts
3800         if ($item['origin'] || in_array($item['network'], [Protocol::DIASPORA])) {
3801                 return [];
3802         }
3803
3804         // Quit if we do now the original author and it had been a post from a native network
3805         if (!empty($item['contact-uid']) && in_array($item['network'], Protocol::NATIVE_SUPPORT)) {
3806                 return [];
3807         }
3808
3809         $fields = ['author-id', 'author-name', 'author-link', 'author-avatar'];
3810         $condition = ['parent-uri' => $item['uri'], 'gravity' => GRAVITY_ACTIVITY, 'uid' => [0, $item['uid']], 'vid' => Verb::getID(Activity::ANNOUNCE)];
3811         $announce = Post::selectFirstForUser($item['uid'], $fields, $condition, ['order' => ['received' => true]]);
3812         if (!DBA::isResult($announce)) {
3813                 return [];
3814         }
3815
3816         return array_merge($item, $announce);
3817 }
3818
3819 /**
3820  *
3821  * @param array $item
3822  *
3823  * @return array
3824  * @throws Exception
3825  */
3826 function api_in_reply_to($item)
3827 {
3828         $in_reply_to = [];
3829
3830         $in_reply_to['status_id'] = null;
3831         $in_reply_to['user_id'] = null;
3832         $in_reply_to['status_id_str'] = null;
3833         $in_reply_to['user_id_str'] = null;
3834         $in_reply_to['screen_name'] = null;
3835
3836         if (($item['thr-parent'] != $item['uri']) && ($item['gravity'] != GRAVITY_PARENT)) {
3837                 $parent = Post::selectFirst(['id'], ['uid' => $item['uid'], 'uri' => $item['thr-parent']]);
3838                 if (DBA::isResult($parent)) {
3839                         $in_reply_to['status_id'] = intval($parent['id']);
3840                 } else {
3841                         $in_reply_to['status_id'] = intval($item['parent']);
3842                 }
3843
3844                 $in_reply_to['status_id_str'] = (string) intval($in_reply_to['status_id']);
3845
3846                 $fields = ['author-nick', 'author-name', 'author-id', 'author-link'];
3847                 $parent = Post::selectFirst($fields, ['id' => $in_reply_to['status_id']]);
3848
3849                 if (DBA::isResult($parent)) {
3850                         $in_reply_to['screen_name'] = (($parent['author-nick']) ? $parent['author-nick'] : $parent['author-name']);
3851                         $in_reply_to['user_id'] = intval($parent['author-id']);
3852                         $in_reply_to['user_id_str'] = (string) intval($parent['author-id']);
3853                 }
3854
3855                 // There seems to be situation, where both fields are identical:
3856                 // https://github.com/friendica/friendica/issues/1010
3857                 // This is a bugfix for that.
3858                 if (intval($in_reply_to['status_id']) == intval($item['id'])) {
3859                         Logger::warning(API_LOG_PREFIX . 'ID {id} is similar to reply-to {reply-to}', ['module' => 'api', 'action' => 'in_reply_to', 'id' => $item['id'], 'reply-to' => $in_reply_to['status_id']]);
3860                         $in_reply_to['status_id'] = null;
3861                         $in_reply_to['user_id'] = null;
3862                         $in_reply_to['status_id_str'] = null;
3863                         $in_reply_to['user_id_str'] = null;
3864                         $in_reply_to['screen_name'] = null;
3865                 }
3866         }
3867
3868         return $in_reply_to;
3869 }
3870
3871 /**
3872  *
3873  * @param string $text
3874  *
3875  * @return string
3876  * @throws InternalServerErrorException
3877  */
3878 function api_clean_plain_items($text)
3879 {
3880         $include_entities = strtolower($_REQUEST['include_entities'] ?? 'false');
3881
3882         $text = BBCode::cleanPictureLinks($text);
3883         $URLSearchString = "^\[\]";
3884
3885         $text = preg_replace("/([!#@])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '$1$3', $text);
3886
3887         if ($include_entities == "true") {
3888                 $text = preg_replace("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '[url=$1]$1[/url]', $text);
3889         }
3890
3891         // Simplify "attachment" element
3892         $text = BBCode::removeAttachment($text);
3893
3894         return $text;
3895 }
3896
3897 /**
3898  * Return all or a specified group of the user with the containing contacts.
3899  *
3900  * @param string $type Return type (atom, rss, xml, json)
3901  *
3902  * @return array|string
3903  * @throws BadRequestException
3904  * @throws ForbiddenException
3905  * @throws ImagickException
3906  * @throws InternalServerErrorException
3907  * @throws UnauthorizedException
3908  */
3909 function api_friendica_group_show($type)
3910 {
3911         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
3912
3913         // params
3914         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
3915         $gid = $_REQUEST['gid'] ?? 0;
3916         $uid = $user_info['uid'];
3917
3918         // get data of the specified group id or all groups if not specified
3919         if ($gid != 0) {
3920                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid, 'id' => $gid]);
3921
3922                 // error message if specified gid is not in database
3923                 if (!DBA::isResult($groups)) {
3924                         throw new BadRequestException("gid not available");
3925                 }
3926         } else {
3927                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid]);
3928         }
3929
3930         // loop through all groups and retrieve all members for adding data in the user array
3931         $grps = [];
3932         foreach ($groups as $rr) {
3933                 $members = Contact\Group::getById($rr['id']);
3934                 $users = [];
3935
3936                 if ($type == "xml") {
3937                         $user_element = "users";
3938                         $k = 0;
3939                         foreach ($members as $member) {
3940                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], BaseApi::getCurrentUserID())->toArray();
3941                                 $users[$k++.":user"] = $user;
3942                         }
3943                 } else {
3944                         $user_element = "user";
3945                         foreach ($members as $member) {
3946                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], BaseApi::getCurrentUserID())->toArray();
3947                                 $users[] = $user;
3948                         }
3949                 }
3950                 $grps[] = ['name' => $rr['name'], 'gid' => $rr['id'], $user_element => $users];
3951         }
3952         return DI::apiResponse()->formatData("groups", $type, ['group' => $grps]);
3953 }
3954
3955 api_register_func('api/friendica/group_show', 'api_friendica_group_show', true);
3956
3957 /**
3958  * Delete a group.
3959  *
3960  * @param string $type Return type (atom, rss, xml, json)
3961  *
3962  * @return array|string
3963  * @throws BadRequestException
3964  * @throws ForbiddenException
3965  * @throws ImagickException
3966  * @throws InternalServerErrorException
3967  * @throws UnauthorizedException
3968  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-destroy
3969  */
3970 function api_lists_destroy($type)
3971 {
3972         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
3973
3974         // params
3975         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
3976         $gid = $_REQUEST['list_id'] ?? 0;
3977         $uid = $user_info['uid'];
3978
3979         // error if no gid specified
3980         if ($gid == 0) {
3981                 throw new BadRequestException('gid not specified');
3982         }
3983
3984         // get data of the specified group id
3985         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
3986         // error message if specified gid is not in database
3987         if (!$group) {
3988                 throw new BadRequestException('gid not available');
3989         }
3990
3991         if (Group::remove($gid)) {
3992                 $list = [
3993                         'name' => $group['name'],
3994                         'id' => intval($gid),
3995                         'id_str' => (string) $gid,
3996                         'user' => $user_info
3997                 ];
3998
3999                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
4000         }
4001 }
4002
4003 api_register_func('api/lists/destroy', 'api_lists_destroy', true, API_METHOD_DELETE);
4004
4005 /**
4006  * Add a new group to the database.
4007  *
4008  * @param  string $name  Group name
4009  * @param  int    $uid   User ID
4010  * @param  array  $users List of users to add to the group
4011  *
4012  * @return array
4013  * @throws BadRequestException
4014  */
4015 function group_create($name, $uid, $users = [])
4016 {
4017         // error if no name specified
4018         if ($name == "") {
4019                 throw new BadRequestException('group name not specified');
4020         }
4021
4022         // error message if specified group name already exists
4023         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => false])) {
4024                 throw new BadRequestException('group name already exists');
4025         }
4026
4027         // Check if the group needs to be reactivated
4028         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => true])) {
4029                 $reactivate_group = true;
4030         }
4031
4032         // create group
4033         $ret = Group::create($uid, $name);
4034         if ($ret) {
4035                 $gid = Group::getIdByName($uid, $name);
4036         } else {
4037                 throw new BadRequestException('other API error');
4038         }
4039
4040         // add members
4041         $erroraddinguser = false;
4042         $errorusers = [];
4043         foreach ($users as $user) {
4044                 $cid = $user['cid'];
4045                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
4046                         Group::addMember($gid, $cid);
4047                 } else {
4048                         $erroraddinguser = true;
4049                         $errorusers[] = $cid;
4050                 }
4051         }
4052
4053         // return success message incl. missing users in array
4054         $status = ($erroraddinguser ? "missing user" : ((isset($reactivate_group) && $reactivate_group) ? "reactivated" : "ok"));
4055
4056         return ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
4057 }
4058
4059 /**
4060  * Create the specified group with the posted array of contacts.
4061  *
4062  * @param string $type Return type (atom, rss, xml, json)
4063  *
4064  * @return array|string
4065  * @throws BadRequestException
4066  * @throws ForbiddenException
4067  * @throws ImagickException
4068  * @throws InternalServerErrorException
4069  * @throws UnauthorizedException
4070  */
4071 function api_friendica_group_create($type)
4072 {
4073         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
4074
4075         // params
4076         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4077         $name = $_REQUEST['name'] ?? '';
4078         $uid = $user_info['uid'];
4079         $json = json_decode($_POST['json'], true);
4080         $users = $json['user'];
4081
4082         $success = group_create($name, $uid, $users);
4083
4084         return DI::apiResponse()->formatData("group_create", $type, ['result' => $success]);
4085 }
4086
4087 api_register_func('api/friendica/group_create', 'api_friendica_group_create', true, API_METHOD_POST);
4088
4089 /**
4090  * Create a new group.
4091  *
4092  * @param string $type Return type (atom, rss, xml, json)
4093  *
4094  * @return array|string
4095  * @throws BadRequestException
4096  * @throws ForbiddenException
4097  * @throws ImagickException
4098  * @throws InternalServerErrorException
4099  * @throws UnauthorizedException
4100  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-create
4101  */
4102 function api_lists_create($type)
4103 {
4104         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
4105
4106         // params
4107         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4108         $name = $_REQUEST['name'] ?? '';
4109         $uid = $user_info['uid'];
4110
4111         $success = group_create($name, $uid);
4112         if ($success['success']) {
4113                 $grp = [
4114                         'name' => $success['name'],
4115                         'id' => intval($success['gid']),
4116                         'id_str' => (string) $success['gid'],
4117                         'user' => $user_info
4118                 ];
4119
4120                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $grp]);
4121         }
4122 }
4123
4124 api_register_func('api/lists/create', 'api_lists_create', true, API_METHOD_POST);
4125
4126 /**
4127  * Update the specified group with the posted array of contacts.
4128  *
4129  * @param string $type Return type (atom, rss, xml, json)
4130  *
4131  * @return array|string
4132  * @throws BadRequestException
4133  * @throws ForbiddenException
4134  * @throws ImagickException
4135  * @throws InternalServerErrorException
4136  * @throws UnauthorizedException
4137  */
4138 function api_friendica_group_update($type)
4139 {
4140         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
4141
4142         // params
4143         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4144         $uid = $user_info['uid'];
4145         $gid = $_REQUEST['gid'] ?? 0;
4146         $name = $_REQUEST['name'] ?? '';
4147         $json = json_decode($_POST['json'], true);
4148         $users = $json['user'];
4149
4150         // error if no name specified
4151         if ($name == "") {
4152                 throw new BadRequestException('group name not specified');
4153         }
4154
4155         // error if no gid specified
4156         if ($gid == "") {
4157                 throw new BadRequestException('gid not specified');
4158         }
4159
4160         // remove members
4161         $members = Contact\Group::getById($gid);
4162         foreach ($members as $member) {
4163                 $cid = $member['id'];
4164                 foreach ($users as $user) {
4165                         $found = ($user['cid'] == $cid ? true : false);
4166                 }
4167                 if (!isset($found) || !$found) {
4168                         $gid = Group::getIdByName($uid, $name);
4169                         Group::removeMember($gid, $cid);
4170                 }
4171         }
4172
4173         // add members
4174         $erroraddinguser = false;
4175         $errorusers = [];
4176         foreach ($users as $user) {
4177                 $cid = $user['cid'];
4178
4179                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
4180                         Group::addMember($gid, $cid);
4181                 } else {
4182                         $erroraddinguser = true;
4183                         $errorusers[] = $cid;
4184                 }
4185         }
4186
4187         // return success message incl. missing users in array
4188         $status = ($erroraddinguser ? "missing user" : "ok");
4189         $success = ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
4190         return DI::apiResponse()->formatData("group_update", $type, ['result' => $success]);
4191 }
4192
4193 api_register_func('api/friendica/group_update', 'api_friendica_group_update', true, API_METHOD_POST);
4194
4195 /**
4196  * Update information about a group.
4197  *
4198  * @param string $type Return type (atom, rss, xml, json)
4199  *
4200  * @return array|string
4201  * @throws BadRequestException
4202  * @throws ForbiddenException
4203  * @throws ImagickException
4204  * @throws InternalServerErrorException
4205  * @throws UnauthorizedException
4206  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-update
4207  */
4208 function api_lists_update($type)
4209 {
4210         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
4211
4212         // params
4213         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4214         $gid = $_REQUEST['list_id'] ?? 0;
4215         $name = $_REQUEST['name'] ?? '';
4216         $uid = $user_info['uid'];
4217
4218         // error if no gid specified
4219         if ($gid == 0) {
4220                 throw new BadRequestException('gid not specified');
4221         }
4222
4223         // get data of the specified group id
4224         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
4225         // error message if specified gid is not in database
4226         if (!$group) {
4227                 throw new BadRequestException('gid not available');
4228         }
4229
4230         if (Group::update($gid, $name)) {
4231                 $list = [
4232                         'name' => $name,
4233                         'id' => intval($gid),
4234                         'id_str' => (string) $gid,
4235                         'user' => $user_info
4236                 ];
4237
4238                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
4239         }
4240 }
4241
4242 api_register_func('api/lists/update', 'api_lists_update', true, API_METHOD_POST);
4243
4244 /**
4245  * Set notification as seen and returns associated item (if possible)
4246  *
4247  * POST request with 'id' param as notification id
4248  *
4249  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
4250  * @return string|array
4251  * @throws BadRequestException
4252  * @throws ForbiddenException
4253  * @throws ImagickException
4254  * @throws InternalServerErrorException
4255  * @throws UnauthorizedException
4256  */
4257 function api_friendica_notification_seen($type)
4258 {
4259         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
4260
4261         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4262
4263         if (DI::args()->getArgc() !== 4) {
4264                 throw new BadRequestException('Invalid argument count');
4265         }
4266
4267         $id = intval($_REQUEST['id'] ?? 0);
4268
4269         try {
4270                 $Notify = DI::notify()->selectOneById($id);
4271                 if ($Notify->uid !== BaseApi::getCurrentUserID()) {
4272                         throw new NotFoundException();
4273                 }
4274
4275                 if ($Notify->uriId) {
4276                         DI::notification()->setAllSeenForUser($Notify->uid, ['target-uri-id' => $Notify->uriId]);
4277                 }
4278
4279                 $Notify->setSeen();
4280                 DI::notify()->save($Notify);
4281
4282                 if ($Notify->otype === Notification\ObjectType::ITEM) {
4283                         $item = Post::selectFirstForUser(BaseApi::getCurrentUserID(), [], ['id' => $Notify->iid, 'uid' => BaseApi::getCurrentUserID()]);
4284                         if (DBA::isResult($item)) {
4285                                 // we found the item, return it to the user
4286                                 $ret  = api_format_items([$item], $type);
4287                                 $data = ['status' => $ret];
4288                                 return DI::apiResponse()->formatData('status', $type, $data);
4289                         }
4290                         // the item can't be found, but we set the notification as seen, so we count this as a success
4291                 }
4292
4293                 return DI::apiResponse()->formatData('result', $type, ['result' => 'success']);
4294         } catch (NotFoundException $e) {
4295                 throw new BadRequestException('Invalid argument', $e);
4296         } catch (Exception $e) {
4297                 throw new InternalServerErrorException('Internal Server exception', $e);
4298         }
4299 }
4300
4301 /// @TODO move to top of file or somewhere better
4302 api_register_func('api/friendica/notification/seen', 'api_friendica_notification_seen', true, API_METHOD_POST);
4303
4304 /**
4305  * search for direct_messages containing a searchstring through api
4306  *
4307  * @param string $type      Known types are 'atom', 'rss', 'xml' and 'json'
4308  * @param string $box
4309  * @return string|array (success: success=true if found and search_result contains found messages,
4310  *                          success=false if nothing was found, search_result='nothing found',
4311  *                          error: result=error with error message)
4312  * @throws BadRequestException
4313  * @throws ForbiddenException
4314  * @throws ImagickException
4315  * @throws InternalServerErrorException
4316  * @throws UnauthorizedException
4317  */
4318 function api_friendica_direct_messages_search($type, $box = "")
4319 {
4320         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
4321
4322         // params
4323         $user_info = DI::twitterUser()->createFromUserId(BaseApi::getCurrentUserID())->toArray();
4324         $searchstring = $_REQUEST['searchstring'] ?? '';
4325         $uid = $user_info['uid'];
4326
4327         // error if no searchstring specified
4328         if ($searchstring == "") {
4329                 $answer = ['result' => 'error', 'message' => 'searchstring not specified'];
4330                 return DI::apiResponse()->formatData("direct_messages_search", $type, ['$result' => $answer]);
4331         }
4332
4333         // get data for the specified searchstring
4334         $r = DBA::toArray(DBA::p(
4335                 "SELECT `mail`.*, `contact`.`nurl` AS `contact-url` FROM `mail`,`contact` WHERE `mail`.`contact-id` = `contact`.`id` AND `mail`.`uid` = ? AND `body` LIKE ? ORDER BY `mail`.`id` DESC",
4336                 $uid,
4337                 '%'.$searchstring.'%'
4338         ));
4339
4340         $profile_url = $user_info["url"];
4341
4342         // message if nothing was found
4343         if (!DBA::isResult($r)) {
4344                 $success = ['success' => false, 'search_results' => 'problem with query'];
4345         } elseif (count($r) == 0) {
4346                 $success = ['success' => false, 'search_results' => 'nothing found'];
4347         } else {
4348                 $ret = [];
4349                 foreach ($r as $item) {
4350                         if ($box == "inbox" || $item['from-url'] != $profile_url) {
4351                                 $recipient = $user_info;
4352                                 $sender = DI::twitterUser()->createFromContactId($item['contact-id'], BaseApi::getCurrentUserID())->toArray();
4353                         } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
4354                                 $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], BaseApi::getCurrentUserID())->toArray();
4355                                 $sender = $user_info;
4356                         }
4357
4358                         if (isset($recipient) && isset($sender)) {
4359                                 $ret[] = api_format_messages($item, $recipient, $sender);
4360                         }
4361                 }
4362                 $success = ['success' => true, 'search_results' => $ret];
4363         }
4364
4365         return DI::apiResponse()->formatData("direct_message_search", $type, ['$result' => $success]);
4366 }
4367
4368 /// @TODO move to top of file or somewhere better
4369 api_register_func('api/friendica/direct_messages_search', 'api_friendica_direct_messages_search', true);
4370
4371 /*
4372  * Number of comments
4373  *
4374  * Bind comment numbers(friendica_comments: Int) on each statuses page of *_timeline / favorites / search
4375  *
4376  * @param object $data [Status, Status]
4377  *
4378  * @return void
4379  */
4380 function bindComments(&$data)
4381 {
4382         if (count($data) == 0) {
4383                 return;
4384         }
4385
4386         $ids = [];
4387         $comments = [];
4388         foreach ($data as $item) {
4389                 $ids[] = $item['id'];
4390         }
4391
4392         $idStr = DBA::escape(implode(', ', $ids));
4393         $sql = "SELECT `parent`, COUNT(*) as comments FROM `post-user-view` WHERE `parent` IN ($idStr) AND `deleted` = ? AND `gravity`= ? GROUP BY `parent`";
4394         $items = DBA::p($sql, 0, GRAVITY_COMMENT);
4395         $itemsData = DBA::toArray($items);
4396
4397         foreach ($itemsData as $item) {
4398                 $comments[$item['parent']] = $item['comments'];
4399         }
4400
4401         foreach ($data as $idx => $item) {
4402                 $id = $item['id'];
4403                 $data[$idx]['friendica_comments'] = isset($comments[$id]) ? $comments[$id] : 0;
4404         }
4405 }
4406
4407 /*
4408 @TODO Maybe open to implement?
4409 To.Do:
4410         [pagename] => api/1.1/statuses/lookup.json
4411         [id] => 605138389168451584
4412         [include_cards] => true
4413         [cards_platform] => Android-12
4414         [include_entities] => true
4415         [include_my_retweet] => 1
4416         [include_rts] => 1
4417         [include_reply_count] => true
4418         [include_descendent_reply_count] => true
4419 (?)
4420
4421
4422 Not implemented by now:
4423 statuses/retweets_of_me
4424 friendships/create
4425 friendships/destroy
4426 friendships/exists
4427 friendships/show
4428 account/update_location
4429 account/update_profile_background_image
4430 blocks/create
4431 blocks/destroy
4432 friendica/profile/update
4433 friendica/profile/create
4434 friendica/profile/delete
4435
4436 Not implemented in status.net:
4437 statuses/retweeted_to_me
4438 statuses/retweeted_by_me
4439 direct_messages/destroy
4440 account/end_session
4441 account/update_delivery_device
4442 notifications/follow
4443 notifications/leave
4444 blocks/exists
4445 blocks/blocking
4446 lists
4447 */