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