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