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