]> git.mxchange.org Git - friendica.git/blob - api.php
929102de3a272aac73146e6934954b285c61ccd5
[friendica.git] / api.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2021, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  * Friendica implementation of statusnet/twitter API
21  *
22  * @file include/api.php
23  * @todo Automatically detect if incoming data is HTML or BBCode
24  */
25
26 use Friendica\App;
27 use Friendica\Content\Text\BBCode;
28 use Friendica\Content\Text\HTML;
29 use Friendica\Core\Logger;
30 use Friendica\Core\Protocol;
31 use Friendica\Core\System;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Model\Contact;
35 use Friendica\Model\Group;
36 use Friendica\Model\Item;
37 use Friendica\Model\Mail;
38 use Friendica\Model\Photo;
39 use Friendica\Model\Post;
40 use Friendica\Model\Profile;
41 use Friendica\Model\User;
42 use Friendica\Module\BaseApi;
43 use Friendica\Network\HTTPException;
44 use Friendica\Network\HTTPException\BadRequestException;
45 use Friendica\Network\HTTPException\ForbiddenException;
46 use Friendica\Network\HTTPException\InternalServerErrorException;
47 use Friendica\Network\HTTPException\NotFoundException;
48 use Friendica\Network\HTTPException\TooManyRequestsException;
49 use Friendica\Network\HTTPException\UnauthorizedException;
50 use Friendica\Object\Image;
51 use Friendica\Util\DateTimeFormat;
52 use Friendica\Util\Images;
53 use Friendica\Util\Network;
54 use Friendica\Util\Strings;
55
56 require_once __DIR__ . '/../mod/item.php';
57 require_once __DIR__ . '/../mod/wall_upload.php';
58
59 $API = [];
60
61 /**
62  * Register a function to be the endpoint for defined API path.
63  *
64  * @param string $path   API URL path, relative to DI::baseUrl()
65  * @param string $func   Function name to call on path request
66  */
67 function api_register_func($path, $func)
68 {
69         global $API;
70
71         $API[$path] = [
72                 'func'   => $func,
73         ];
74
75         // Workaround for hotot
76         $path = str_replace("api/", "api/1.1/", $path);
77
78         $API[$path] = [
79                 'func'   => $func,
80         ];
81 }
82
83 /**
84  * Main API entry point
85  *
86  * Authenticate user, call registered API function, set HTTP headers
87  *
88  * @param App\Arguments $args The app arguments (optional, will retrieved by the DI-Container in case of missing)
89  * @return string|array API call result
90  * @throws Exception
91  */
92 function api_call($command, $extension)
93 {
94         global $API;
95
96         Logger::info('Legacy API call', ['command' => $command, 'extension' => $extension]);
97
98         try {
99                 foreach ($API as $p => $info) {
100                         if (strpos($command, $p) === 0) {
101                                 Logger::debug(BaseApi::LOG_PREFIX . 'parameters', ['module' => 'api', 'action' => 'call', 'parameters' => $_REQUEST]);
102
103                                 $stamp =  microtime(true);
104                                 $return = call_user_func($info['func'], $extension);
105                                 $duration = floatval(microtime(true) - $stamp);
106
107                                 Logger::info(BaseApi::LOG_PREFIX . 'duration {duration}', ['module' => 'api', 'action' => 'call', 'duration' => round($duration, 2)]);
108
109                                 DI::profiler()->saveLog(DI::logger(), BaseApi::LOG_PREFIX . 'performance');
110
111                                 if (false === $return) {
112                                         /*
113                                                 * api function returned false withour throw an
114                                                 * exception. This should not happend, throw a 500
115                                                 */
116                                         throw new InternalServerErrorException();
117                                 }
118
119                                 switch ($extension) {
120                                         case "xml":
121                                                 header("Content-Type: text/xml");
122                                                 break;
123                                         case "json":
124                                                 header("Content-Type: application/json");
125                                                 if (!empty($return)) {
126                                                         $json = json_encode(end($return));
127                                                         if (!empty($_GET['callback'])) {
128                                                                 $json = $_GET['callback'] . "(" . $json . ")";
129                                                         }
130                                                         $return = $json;
131                                                 }
132                                                 break;
133                                         case "rss":
134                                                 header("Content-Type: application/rss+xml");
135                                                 $return  = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $return;
136                                                 break;
137                                         case "atom":
138                                                 header("Content-Type: application/atom+xml");
139                                                 $return = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $return;
140                                                 break;
141                                 }
142                                 return $return;
143                         }
144                 }
145
146                 Logger::warning(BaseApi::LOG_PREFIX . 'not implemented', ['module' => 'api', 'action' => 'call', 'query' => DI::args()->getQueryString()]);
147                 throw new NotFoundException();
148         } catch (HTTPException $e) {
149                 Logger::notice(BaseApi::LOG_PREFIX . 'got exception', ['module' => 'api', 'action' => 'call', 'query' => DI::args()->getQueryString(), 'error' => $e]);
150                 DI::apiResponse()->error($e->getCode(), $e->getDescription(), $e->getMessage(), $extension);
151         }
152 }
153
154 /**
155  *
156  * @param array $item
157  * @param array $recipient
158  * @param array $sender
159  *
160  * @return array
161  * @throws InternalServerErrorException
162  */
163 function api_format_messages($item, $recipient, $sender)
164 {
165         // standard meta information
166         $ret = [
167                 'id'                    => $item['id'],
168                 'sender_id'             => $sender['id'],
169                 'text'                  => "",
170                 'recipient_id'          => $recipient['id'],
171                 'created_at'            => DateTimeFormat::utc($item['created'] ?? 'now', DateTimeFormat::API),
172                 'sender_screen_name'    => $sender['screen_name'],
173                 'recipient_screen_name' => $recipient['screen_name'],
174                 'sender'                => $sender,
175                 'recipient'             => $recipient,
176                 'title'                 => "",
177                 'friendica_seen'        => $item['seen'] ?? 0,
178                 'friendica_parent_uri'  => $item['parent-uri'] ?? '',
179         ];
180
181         // "uid" is only needed for some internal stuff, so remove it from here
182         if (isset($ret['sender']['uid'])) {
183                 unset($ret['sender']['uid']);
184         }
185         if (isset($ret['recipient']['uid'])) {
186                 unset($ret['recipient']['uid']);
187         }
188
189         //don't send title to regular StatusNET requests to avoid confusing these apps
190         if (!empty($_GET['getText'])) {
191                 $ret['title'] = $item['title'];
192                 if ($_GET['getText'] == 'html') {
193                         $ret['text'] = BBCode::convertForUriId($item['uri-id'], $item['body'], BBCode::API);
194                 } elseif ($_GET['getText'] == 'plain') {
195                         $ret['text'] = trim(HTML::toPlaintext(BBCode::convertForUriId($item['uri-id'], api_clean_plain_items($item['body']), BBCode::API), 0));
196                 }
197         } else {
198                 $ret['text'] = $item['title'] . "\n" . HTML::toPlaintext(BBCode::convertForUriId($item['uri-id'], api_clean_plain_items($item['body']), BBCode::API), 0);
199         }
200         if (!empty($_GET['getUserObjects']) && $_GET['getUserObjects'] == 'false') {
201                 unset($ret['sender']);
202                 unset($ret['recipient']);
203         }
204
205         return $ret;
206 }
207
208 /**
209  *
210  * @param string $acl_string
211  * @param int    $uid
212  * @return bool
213  * @throws Exception
214  */
215 function check_acl_input($acl_string, $uid)
216 {
217         if (empty($acl_string)) {
218                 return false;
219         }
220
221         $contact_not_found = false;
222
223         // split <x><y><z> into array of cid's
224         preg_match_all("/<[A-Za-z0-9]+>/", $acl_string, $array);
225
226         // check for each cid if it is available on server
227         $cid_array = $array[0];
228         foreach ($cid_array as $cid) {
229                 $cid = str_replace("<", "", $cid);
230                 $cid = str_replace(">", "", $cid);
231                 $condition = ['id' => $cid, 'uid' => $uid];
232                 $contact_not_found |= !DBA::exists('contact', $condition);
233         }
234         return $contact_not_found;
235 }
236
237 /**
238  * @param string  $mediatype
239  * @param array   $media
240  * @param string  $type
241  * @param string  $album
242  * @param string  $allow_cid
243  * @param string  $deny_cid
244  * @param string  $allow_gid
245  * @param string  $deny_gid
246  * @param string  $desc
247  * @param integer $phototype
248  * @param boolean $visibility
249  * @param string  $photo_id
250  * @param int     $uid
251  * @return array
252  * @throws BadRequestException
253  * @throws ForbiddenException
254  * @throws ImagickException
255  * @throws InternalServerErrorException
256  * @throws NotFoundException
257  * @throws UnauthorizedException
258  */
259 function save_media_to_database($mediatype, $media, $type, $album, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $desc, $phototype, $visibility, $photo_id, $uid)
260 {
261         $visitor   = 0;
262         $src = "";
263         $filetype = "";
264         $filename = "";
265         $filesize = 0;
266
267         if (is_array($media)) {
268                 if (is_array($media['tmp_name'])) {
269                         $src = $media['tmp_name'][0];
270                 } else {
271                         $src = $media['tmp_name'];
272                 }
273                 if (is_array($media['name'])) {
274                         $filename = basename($media['name'][0]);
275                 } else {
276                         $filename = basename($media['name']);
277                 }
278                 if (is_array($media['size'])) {
279                         $filesize = intval($media['size'][0]);
280                 } else {
281                         $filesize = intval($media['size']);
282                 }
283                 if (is_array($media['type'])) {
284                         $filetype = $media['type'][0];
285                 } else {
286                         $filetype = $media['type'];
287                 }
288         }
289
290         $filetype = Images::getMimeTypeBySource($src, $filename, $filetype);
291
292         logger::info(
293                 "File upload src: " . $src . " - filename: " . $filename .
294                 " - size: " . $filesize . " - type: " . $filetype);
295
296         // check if there was a php upload error
297         if ($filesize == 0 && $media['error'] == 1) {
298                 throw new InternalServerErrorException("image size exceeds PHP config settings, file was rejected by server");
299         }
300         // check against max upload size within Friendica instance
301         $maximagesize = DI::config()->get('system', 'maximagesize');
302         if ($maximagesize && ($filesize > $maximagesize)) {
303                 $formattedBytes = Strings::formatBytes($maximagesize);
304                 throw new InternalServerErrorException("image size exceeds Friendica config setting (uploaded size: $formattedBytes)");
305         }
306
307         // create Photo instance with the data of the image
308         $imagedata = @file_get_contents($src);
309         $Image = new Image($imagedata, $filetype);
310         if (!$Image->isValid()) {
311                 throw new InternalServerErrorException("unable to process image data");
312         }
313
314         // check orientation of image
315         $Image->orient($src);
316         @unlink($src);
317
318         // check max length of images on server
319         $max_length = DI::config()->get('system', 'max_image_length');
320         if ($max_length > 0) {
321                 $Image->scaleDown($max_length);
322                 logger::info("File upload: Scaling picture to new size " . $max_length);
323         }
324         $width = $Image->getWidth();
325         $height = $Image->getHeight();
326
327         // create a new resource-id if not already provided
328         $resource_id = ($photo_id == null) ? Photo::newResource() : $photo_id;
329
330         if ($mediatype == "photo") {
331                 // upload normal image (scales 0, 1, 2)
332                 logger::info("photo upload: starting new photo upload");
333
334                 $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 0, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
335                 if (!$r) {
336                         logger::notice("photo upload: image upload with scale 0 (original size) failed");
337                 }
338                 if ($width > 640 || $height > 640) {
339                         $Image->scaleDown(640);
340                         $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 1, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
341                         if (!$r) {
342                                 logger::notice("photo upload: image upload with scale 1 (640x640) failed");
343                         }
344                 }
345
346                 if ($width > 320 || $height > 320) {
347                         $Image->scaleDown(320);
348                         $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 2, Photo::DEFAULT, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
349                         if (!$r) {
350                                 logger::notice("photo upload: image upload with scale 2 (320x320) failed");
351                         }
352                 }
353                 logger::info("photo upload: new photo upload ended");
354         } elseif ($mediatype == "profileimage") {
355                 // upload profile image (scales 4, 5, 6)
356                 logger::info("photo upload: starting new profile image upload");
357
358                 if ($width > 300 || $height > 300) {
359                         $Image->scaleDown(300);
360                         $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 4, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
361                         if (!$r) {
362                                 logger::notice("photo upload: profile image upload with scale 4 (300x300) failed");
363                         }
364                 }
365
366                 if ($width > 80 || $height > 80) {
367                         $Image->scaleDown(80);
368                         $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 5, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
369                         if (!$r) {
370                                 logger::notice("photo upload: profile image upload with scale 5 (80x80) failed");
371                         }
372                 }
373
374                 if ($width > 48 || $height > 48) {
375                         $Image->scaleDown(48);
376                         $r = Photo::store($Image, $uid, $visitor, $resource_id, $filename, $album, 6, $phototype, $allow_cid, $allow_gid, $deny_cid, $deny_gid, $desc);
377                         if (!$r) {
378                                 logger::notice("photo upload: profile image upload with scale 6 (48x48) failed");
379                         }
380                 }
381                 $Image->__destruct();
382                 logger::info("photo upload: new profile image upload ended");
383         }
384
385         if (!empty($r)) {
386                 // create entry in 'item'-table on new uploads to enable users to comment/like/dislike the photo
387                 if ($photo_id == null && $mediatype == "photo") {
388                         post_photo_item($resource_id, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility, $uid);
389                 }
390                 // on success return image data in json/xml format (like /api/friendica/photo does when no scale is given)
391                 return prepare_photo_data($type, false, $resource_id, $uid);
392         } else {
393                 throw new InternalServerErrorException("image upload failed");
394         }
395 }
396
397 /**
398  *
399  * @param string  $hash
400  * @param string  $allow_cid
401  * @param string  $deny_cid
402  * @param string  $allow_gid
403  * @param string  $deny_gid
404  * @param string  $filetype
405  * @param boolean $visibility
406  * @param int     $uid
407  * @throws InternalServerErrorException
408  */
409 function post_photo_item($hash, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility, $uid)
410 {
411         // get data about the api authenticated user
412         $uri = Item::newURI(intval($uid));
413         $owner_record = DBA::selectFirst('contact', [], ['uid' => $uid, 'self' => true]);
414
415         $arr = [];
416         $arr['guid']          = System::createUUID();
417         $arr['uid']           = intval($uid);
418         $arr['uri']           = $uri;
419         $arr['type']          = 'photo';
420         $arr['wall']          = 1;
421         $arr['resource-id']   = $hash;
422         $arr['contact-id']    = $owner_record['id'];
423         $arr['owner-name']    = $owner_record['name'];
424         $arr['owner-link']    = $owner_record['url'];
425         $arr['owner-avatar']  = $owner_record['thumb'];
426         $arr['author-name']   = $owner_record['name'];
427         $arr['author-link']   = $owner_record['url'];
428         $arr['author-avatar'] = $owner_record['thumb'];
429         $arr['title']         = "";
430         $arr['allow_cid']     = $allow_cid;
431         $arr['allow_gid']     = $allow_gid;
432         $arr['deny_cid']      = $deny_cid;
433         $arr['deny_gid']      = $deny_gid;
434         $arr['visible']       = $visibility;
435         $arr['origin']        = 1;
436
437         $typetoext = [
438                         'image/jpeg' => 'jpg',
439                         'image/png' => 'png',
440                         'image/gif' => 'gif'
441                         ];
442
443         // adds link to the thumbnail scale photo
444         $arr['body'] = '[url=' . DI::baseUrl() . '/photos/' . $owner_record['nick'] . '/image/' . $hash . ']'
445                                 . '[img]' . DI::baseUrl() . '/photo/' . $hash . '-' . "2" . '.'. $typetoext[$filetype] . '[/img]'
446                                 . '[/url]';
447
448         // do the magic for storing the item in the database and trigger the federation to other contacts
449         Item::insert($arr);
450 }
451
452 /**
453  *
454  * @param string $type
455  * @param int    $scale
456  * @param string $photo_id
457  *
458  * @return array
459  * @throws BadRequestException
460  * @throws ForbiddenException
461  * @throws ImagickException
462  * @throws InternalServerErrorException
463  * @throws NotFoundException
464  * @throws UnauthorizedException
465  */
466 function prepare_photo_data($type, $scale, $photo_id, $uid)
467 {
468         $scale_sql = ($scale === false ? "" : sprintf("AND scale=%d", intval($scale)));
469         $data_sql = ($scale === false ? "" : "data, ");
470
471         // added allow_cid, allow_gid, deny_cid, deny_gid to output as string like stored in database
472         // clients needs to convert this in their way for further processing
473         $r = DBA::toArray(DBA::p(
474                 "SELECT $data_sql `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
475                                         `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`,
476                                         MIN(`scale`) AS `minscale`, MAX(`scale`) AS `maxscale`
477                         FROM `photo` WHERE `uid` = ? AND `resource-id` = ? $scale_sql GROUP BY
478                                    `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
479                                    `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`",
480                 $uid,
481                 $photo_id
482         ));
483
484         $typetoext = [
485                 'image/jpeg' => 'jpg',
486                 'image/png' => 'png',
487                 'image/gif' => 'gif'
488         ];
489
490         // prepare output data for photo
491         if (DBA::isResult($r)) {
492                 $data = ['photo' => $r[0]];
493                 $data['photo']['id'] = $data['photo']['resource-id'];
494                 if ($scale !== false) {
495                         $data['photo']['data'] = base64_encode($data['photo']['data']);
496                 } else {
497                         unset($data['photo']['datasize']); //needed only with scale param
498                 }
499                 if ($type == "xml") {
500                         $data['photo']['links'] = [];
501                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
502                                 $data['photo']['links'][$k . ":link"]["@attributes"] = ["type" => $data['photo']['type'],
503                                                                                 "scale" => $k,
504                                                                                 "href" => DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']]];
505                         }
506                 } else {
507                         $data['photo']['link'] = [];
508                         // when we have profile images we could have only scales from 4 to 6, but index of array always needs to start with 0
509                         $i = 0;
510                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
511                                 $data['photo']['link'][$i] = DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']];
512                                 $i++;
513                         }
514                 }
515                 unset($data['photo']['resource-id']);
516                 unset($data['photo']['minscale']);
517                 unset($data['photo']['maxscale']);
518         } else {
519                 throw new NotFoundException();
520         }
521
522         // retrieve item element for getting activities (like, dislike etc.) related to photo
523         $condition = ['uid' => $uid, 'resource-id' => $photo_id];
524         $item = Post::selectFirst(['id', 'uid', 'uri', 'parent', 'allow_cid', 'deny_cid', 'allow_gid', 'deny_gid'], $condition);
525         if (!DBA::isResult($item)) {
526                 throw new NotFoundException('Photo-related item not found.');
527         }
528
529         $data['photo']['friendica_activities'] = DI::friendicaActivities()->createFromUriId($item['uri-id'], $item['uid'], $type);
530
531         // retrieve comments on photo
532         $condition = ["`parent` = ? AND `uid` = ? AND `gravity` IN (?, ?)",
533                 $item['parent'], $uid, GRAVITY_PARENT, GRAVITY_COMMENT];
534
535         $statuses = Post::selectForUser($uid, [], $condition);
536
537         // prepare output of comments
538         $commentData = [];
539         while ($status = DBA::fetch($statuses)) {
540                 $commentData[] = DI::twitterStatus()->createFromUriId($status['uri-id'], $status['uid'])->toArray();
541         }
542         DBA::close($statuses);
543
544         $comments = [];
545         if ($type == "xml") {
546                 $k = 0;
547                 foreach ($commentData as $comment) {
548                         $comments[$k++ . ":comment"] = $comment;
549                 }
550         } else {
551                 foreach ($commentData as $comment) {
552                         $comments[] = $comment;
553                 }
554         }
555         $data['photo']['friendica_comments'] = $comments;
556
557         // include info if rights on photo and rights on item are mismatching
558         $rights_mismatch = $data['photo']['allow_cid'] != $item['allow_cid'] ||
559                 $data['photo']['deny_cid'] != $item['deny_cid'] ||
560                 $data['photo']['allow_gid'] != $item['allow_gid'] ||
561                 $data['photo']['deny_gid'] != $item['deny_gid'];
562         $data['photo']['rights_mismatch'] = $rights_mismatch;
563
564         return $data;
565 }
566
567 /**
568  *
569  * @param string $text
570  *
571  * @return string
572  * @throws InternalServerErrorException
573  */
574 function api_clean_plain_items($text)
575 {
576         $include_entities = strtolower($_REQUEST['include_entities'] ?? 'false');
577
578         $text = BBCode::cleanPictureLinks($text);
579         $URLSearchString = "^\[\]";
580
581         $text = preg_replace("/([!#@])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '$1$3', $text);
582
583         if ($include_entities == "true") {
584                 $text = preg_replace("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '[url=$1]$1[/url]', $text);
585         }
586
587         // Simplify "attachment" element
588         $text = BBCode::removeAttachment($text);
589
590         return $text;
591 }
592
593 /**
594  * Add a new group to the database.
595  *
596  * @param  string $name  Group name
597  * @param  int    $uid   User ID
598  * @param  array  $users List of users to add to the group
599  *
600  * @return array
601  * @throws BadRequestException
602  */
603 function group_create($name, $uid, $users = [])
604 {
605         // error if no name specified
606         if ($name == "") {
607                 throw new BadRequestException('group name not specified');
608         }
609
610         // error message if specified group name already exists
611         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => false])) {
612                 throw new BadRequestException('group name already exists');
613         }
614
615         // Check if the group needs to be reactivated
616         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => true])) {
617                 $reactivate_group = true;
618         }
619
620         // create group
621         $ret = Group::create($uid, $name);
622         if ($ret) {
623                 $gid = Group::getIdByName($uid, $name);
624         } else {
625                 throw new BadRequestException('other API error');
626         }
627
628         // add members
629         $erroraddinguser = false;
630         $errorusers = [];
631         foreach ($users as $user) {
632                 $cid = $user['cid'];
633                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
634                         Group::addMember($gid, $cid);
635                 } else {
636                         $erroraddinguser = true;
637                         $errorusers[] = $cid;
638                 }
639         }
640
641         // return success message incl. missing users in array
642         $status = ($erroraddinguser ? "missing user" : ((isset($reactivate_group) && $reactivate_group) ? "reactivated" : "ok"));
643
644         return ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
645 }
646
647 /**
648  * TWITTER API
649  */
650
651 /**
652  * Deprecated function to upload media.
653  *
654  * @param string $type Return type (atom, rss, xml, json)
655  *
656  * @return array|string
657  * @throws BadRequestException
658  * @throws ForbiddenException
659  * @throws ImagickException
660  * @throws InternalServerErrorException
661  * @throws UnauthorizedException
662  */
663 function api_statuses_mediap($type)
664 {
665         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
666         $uid = BaseApi::getCurrentUserID();
667
668         $a = DI::app();
669
670         $_REQUEST['profile_uid'] = $uid;
671         $_REQUEST['api_source'] = true;
672         $txt = $_REQUEST['status'] ?? '';
673
674         if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
675                 $txt = HTML::toBBCodeVideo($txt);
676                 $config = HTMLPurifier_Config::createDefault();
677                 $config->set('Cache.DefinitionImpl', null);
678                 $purifier = new HTMLPurifier($config);
679                 $txt = $purifier->purify($txt);
680         }
681         $txt = HTML::toBBCode($txt);
682
683         $picture = wall_upload_post($a, false);
684
685         // now that we have the img url in bbcode we can add it to the status and insert the wall item.
686         $_REQUEST['body'] = $txt . "\n\n" . '[url=' . $picture["albumpage"] . '][img]' . $picture["preview"] . "[/img][/url]";
687         $item_id = item_post($a);
688
689         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
690
691         // output the post that we just posted.
692         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
693         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
694 }
695
696 /// @TODO move this to top of file or somewhere better!
697 api_register_func('api/statuses/mediap', 'api_statuses_mediap', true);
698
699 /**
700  * Updates the user’s current status.
701  *
702  * @param string $type Return type (atom, rss, xml, json)
703  *
704  * @return array|string
705  * @throws BadRequestException
706  * @throws ForbiddenException
707  * @throws ImagickException
708  * @throws InternalServerErrorException
709  * @throws TooManyRequestsException
710  * @throws UnauthorizedException
711  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
712  */
713 function api_statuses_update($type)
714 {
715         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
716         $uid = BaseApi::getCurrentUserID();
717
718         $a = DI::app();
719
720         // convert $_POST array items to the form we use for web posts.
721         if (!empty($_REQUEST['htmlstatus'])) {
722                 $txt = $_REQUEST['htmlstatus'];
723                 if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
724                         $txt = HTML::toBBCodeVideo($txt);
725
726                         $config = HTMLPurifier_Config::createDefault();
727                         $config->set('Cache.DefinitionImpl', null);
728
729                         $purifier = new HTMLPurifier($config);
730                         $txt = $purifier->purify($txt);
731
732                         $_REQUEST['body'] = HTML::toBBCode($txt);
733                 }
734         } else {
735                 $_REQUEST['body'] = $_REQUEST['status'] ?? null;
736         }
737
738         $_REQUEST['title'] = $_REQUEST['title'] ?? null;
739
740         $parent = $_REQUEST['in_reply_to_status_id'] ?? null;
741
742         // Twidere sends "-1" if it is no reply ...
743         if ($parent == -1) {
744                 $parent = "";
745         }
746
747         if (ctype_digit($parent)) {
748                 $_REQUEST['parent'] = $parent;
749         } else {
750                 $_REQUEST['parent_uri'] = $parent;
751         }
752
753         if (!empty($_REQUEST['lat']) && !empty($_REQUEST['long'])) {
754                 $_REQUEST['coord'] = sprintf("%s %s", $_REQUEST['lat'], $_REQUEST['long']);
755         }
756         $_REQUEST['profile_uid'] = $uid;
757
758         if (!$parent) {
759                 // Check for throttling (maximum posts per day, week and month)
760                 $throttle_day = DI::config()->get('system', 'throttle_limit_day');
761                 if ($throttle_day > 0) {
762                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60);
763
764                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
765                         $posts_day = Post::count($condition);
766
767                         if ($posts_day > $throttle_day) {
768                                 logger::info('Daily posting limit reached for user ' . $uid);
769                                 // die(api_error($type, DI::l10n()->t("Daily posting limit of %d posts reached. The post was rejected.", $throttle_day));
770                                 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));
771                         }
772                 }
773
774                 $throttle_week = DI::config()->get('system', 'throttle_limit_week');
775                 if ($throttle_week > 0) {
776                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*7);
777
778                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
779                         $posts_week = Post::count($condition);
780
781                         if ($posts_week > $throttle_week) {
782                                 logger::info('Weekly posting limit reached for user ' . $uid);
783                                 // die(api_error($type, DI::l10n()->t("Weekly posting limit of %d posts reached. The post was rejected.", $throttle_week)));
784                                 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));
785                         }
786                 }
787
788                 $throttle_month = DI::config()->get('system', 'throttle_limit_month');
789                 if ($throttle_month > 0) {
790                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*30);
791
792                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
793                         $posts_month = Post::count($condition);
794
795                         if ($posts_month > $throttle_month) {
796                                 logger::info('Monthly posting limit reached for user ' . $uid);
797                                 // die(api_error($type, DI::l10n()->t("Monthly posting limit of %d posts reached. The post was rejected.", $throttle_month));
798                                 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));
799                         }
800                 }
801         }
802
803         if (!empty($_REQUEST['media_ids'])) {
804                 $ids = explode(',', $_REQUEST['media_ids']);
805         } elseif (!empty($_FILES['media'])) {
806                 // upload the image if we have one
807                 $picture = wall_upload_post($a, false);
808                 if (is_array($picture)) {
809                         $ids[] = $picture['id'];
810                 }
811         }
812
813         $attachments = [];
814         $ressources = [];
815
816         if (!empty($ids)) {
817                 foreach ($ids as $id) {
818                         $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `nickname`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
819                                         INNER JOIN `user` ON `user`.`uid` = `photo`.`uid` WHERE `resource-id` IN
820                                                 (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
821                                         ORDER BY `photo`.`width` DESC LIMIT 2", $id, $uid));
822
823                         if (!empty($media)) {
824                                 $ressources[] = $media[0]['resource-id'];
825                                 $phototypes = Images::supportedTypes();
826                                 $ext = $phototypes[$media[0]['type']];
827
828                                 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
829                                         'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
830                                         'size' => $media[0]['datasize'],
831                                         'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
832                                         'description' => $media[0]['desc'] ?? '',
833                                         'width' => $media[0]['width'],
834                                         'height' => $media[0]['height']];
835
836                                 if (count($media) > 1) {
837                                         $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
838                                         $attachment['preview-width'] = $media[1]['width'];
839                                         $attachment['preview-height'] = $media[1]['height'];
840                                 }
841                                 $attachments[] = $attachment;
842                         }
843                 }
844
845                 // We have to avoid that the post is rejected because of an empty body
846                 if (empty($_REQUEST['body'])) {
847                         $_REQUEST['body'] = '[hr]';
848                 }
849         }
850
851         if (!empty($attachments)) {
852                 $_REQUEST['attachments'] = $attachments;
853         }
854
855         // set this so that the item_post() function is quiet and doesn't redirect or emit json
856
857         $_REQUEST['api_source'] = true;
858
859         if (empty($_REQUEST['source'])) {
860                 $_REQUEST['source'] = BaseApi::getCurrentApplication()['name'] ?: 'API';
861         }
862
863         // call out normal post function
864         $item_id = item_post($a);
865
866         if (!empty($ressources) && !empty($item_id)) {
867                 $item = Post::selectFirst(['uri-id', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid'], ['id' => $item_id]);
868                 foreach ($ressources as $ressource) {
869                         Photo::setPermissionForRessource($ressource, $uid, $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
870                 }
871         }
872
873         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
874
875         // output the post that we just posted.
876         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
877         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
878 }
879
880 api_register_func('api/statuses/update', 'api_statuses_update', true);
881 api_register_func('api/statuses/update_with_media', 'api_statuses_update', true);
882
883 /**
884  * Uploads an image to Friendica.
885  *
886  * @return array
887  * @throws BadRequestException
888  * @throws ForbiddenException
889  * @throws ImagickException
890  * @throws InternalServerErrorException
891  * @throws UnauthorizedException
892  * @see https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload
893  */
894 function api_media_upload()
895 {
896         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
897
898         if (empty($_FILES['media'])) {
899                 // Output error
900                 throw new BadRequestException("No media.");
901         }
902
903         $media = wall_upload_post(DI::app(), false);
904         if (!$media) {
905                 // Output error
906                 throw new InternalServerErrorException();
907         }
908
909         $returndata = [];
910         $returndata["media_id"] = $media["id"];
911         $returndata["media_id_string"] = (string)$media["id"];
912         $returndata["size"] = $media["size"];
913         $returndata["image"] = ["w" => $media["width"],
914                                 "h" => $media["height"],
915                                 "image_type" => $media["type"],
916                                 "friendica_preview_url" => $media["preview"]];
917
918         Logger::info('Media uploaded', ['return' => $returndata]);
919
920         return ["media" => $returndata];
921 }
922
923 api_register_func('api/media/upload', 'api_media_upload', true);
924
925 /**
926  * Updates media meta data (picture descriptions)
927  *
928  * @param string $type Return type (atom, rss, xml, json)
929  *
930  * @return array|string
931  * @throws BadRequestException
932  * @throws ForbiddenException
933  * @throws ImagickException
934  * @throws InternalServerErrorException
935  * @throws TooManyRequestsException
936  * @throws UnauthorizedException
937  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
938  *
939  * @todo Compare the corresponding Twitter function for correct return values
940  */
941 function api_media_metadata_create($type)
942 {
943         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
944         $uid = BaseApi::getCurrentUserID();
945
946         $postdata = Network::postdata();
947
948         if (empty($postdata)) {
949                 throw new BadRequestException("No post data");
950         }
951
952         $data = json_decode($postdata, true);
953         if (empty($data)) {
954                 throw new BadRequestException("Invalid post data");
955         }
956
957         if (empty($data['media_id']) || empty($data['alt_text'])) {
958                 throw new BadRequestException("Missing post data values");
959         }
960
961         if (empty($data['alt_text']['text'])) {
962                 throw new BadRequestException("No alt text.");
963         }
964
965         Logger::info('Updating metadata', ['media_id' => $data['media_id']]);
966
967         $condition = ['id' => $data['media_id'], 'uid' => $uid];
968         $photo = DBA::selectFirst('photo', ['resource-id'], $condition);
969         if (!DBA::isResult($photo)) {
970                 throw new BadRequestException("Metadata not found.");
971         }
972
973         DBA::update('photo', ['desc' => $data['alt_text']['text']], ['resource-id' => $photo['resource-id']]);
974 }
975
976 api_register_func('api/media/metadata/create', 'api_media_metadata_create', true);
977
978 /**
979  * Repeats a status.
980  *
981  * @param string $type Return type (atom, rss, xml, json)
982  *
983  * @return array|string
984  * @throws BadRequestException
985  * @throws ForbiddenException
986  * @throws ImagickException
987  * @throws InternalServerErrorException
988  * @throws UnauthorizedException
989  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-retweet-id
990  */
991 function api_statuses_repeat($type)
992 {
993         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
994         $uid = BaseApi::getCurrentUserID();
995
996         // params
997         $id = intval(DI::args()->getArgv()[3] ?? 0);
998
999         if ($id == 0) {
1000                 $id = intval($_REQUEST['id'] ?? 0);
1001         }
1002
1003         // Hotot workaround
1004         if ($id == 0) {
1005                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1006         }
1007
1008         logger::notice('API: api_statuses_repeat: ' . $id);
1009
1010         $fields = ['uri-id', 'network', 'body', 'title', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink'];
1011         $item = Post::selectFirst($fields, ['id' => $id, 'private' => [Item::PUBLIC, Item::UNLISTED]]);
1012
1013         if (DBA::isResult($item) && !empty($item['body'])) {
1014                 if (in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::TWITTER])) {
1015                         if (!Item::performActivity($id, 'announce', $uid)) {
1016                                 throw new InternalServerErrorException();
1017                         }
1018
1019                         $item_id = $id;
1020                 } else {
1021                         if (strpos($item['body'], "[/share]") !== false) {
1022                                 $pos = strpos($item['body'], "[share");
1023                                 $post = substr($item['body'], $pos);
1024                         } else {
1025                                 $post = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']);
1026
1027                                 if (!empty($item['title'])) {
1028                                         $post .= '[h3]' . $item['title'] . "[/h3]\n";
1029                                 }
1030
1031                                 $post .= $item['body'];
1032                                 $post .= "[/share]";
1033                         }
1034                         $_REQUEST['body'] = $post;
1035                         $_REQUEST['profile_uid'] = $uid;
1036                         $_REQUEST['api_source'] = true;
1037
1038                         if (empty($_REQUEST['source'])) {
1039                                 $_REQUEST['source'] = BaseApi::getCurrentApplication()['name'] ?: 'API';
1040                         }
1041
1042                         $item_id = item_post(DI::app());
1043                 }
1044         } else {
1045                 throw new ForbiddenException();
1046         }
1047
1048         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
1049
1050         // output the post that we just posted.
1051         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
1052         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
1053 }
1054
1055 api_register_func('api/statuses/retweet', 'api_statuses_repeat', true);
1056
1057 /**
1058  * Star/unstar an item.
1059  * param: id : id of the item
1060  *
1061  * @param string $type Return type (atom, rss, xml, json)
1062  *
1063  * @return array|string
1064  * @throws BadRequestException
1065  * @throws ForbiddenException
1066  * @throws ImagickException
1067  * @throws InternalServerErrorException
1068  * @throws UnauthorizedException
1069  * @see https://web.archive.org/web/20131019055350/https://dev.twitter.com/docs/api/1/post/favorites/create/%3Aid
1070  */
1071 function api_favorites_create_destroy($type)
1072 {
1073         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1074         $uid = BaseApi::getCurrentUserID();
1075
1076         // for versioned api.
1077         /// @TODO We need a better global soluton
1078         $action_argv_id = 2;
1079         if (count(DI::args()->getArgv()) > 1 && DI::args()->getArgv()[1] == "1.1") {
1080                 $action_argv_id = 3;
1081         }
1082
1083         if (DI::args()->getArgc() <= $action_argv_id) {
1084                 throw new BadRequestException("Invalid request.");
1085         }
1086         $action = str_replace("." . $type, "", DI::args()->getArgv()[$action_argv_id]);
1087         if (DI::args()->getArgc() == $action_argv_id + 2) {
1088                 $itemid = intval(DI::args()->getArgv()[$action_argv_id + 1] ?? 0);
1089         } else {
1090                 $itemid = intval($_REQUEST['id'] ?? 0);
1091         }
1092
1093         $item = Post::selectFirstForUser($uid, [], ['id' => $itemid, 'uid' => $uid]);
1094
1095         if (!DBA::isResult($item)) {
1096                 throw new BadRequestException("Invalid item.");
1097         }
1098
1099         switch ($action) {
1100                 case "create":
1101                         $item['starred'] = 1;
1102                         break;
1103                 case "destroy":
1104                         $item['starred'] = 0;
1105                         break;
1106                 default:
1107                         throw new BadRequestException("Invalid action ".$action);
1108         }
1109
1110         $r = Item::update(['starred' => $item['starred']], ['id' => $itemid]);
1111
1112         if ($r === false) {
1113                 throw new InternalServerErrorException("DB error");
1114         }
1115
1116         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
1117
1118         $ret = DI::twitterStatus()->createFromUriId($item['uri-id'], $item['uid'], $include_entities)->toArray();
1119
1120         return DI::apiResponse()->formatData("status", $type, ['status' => $ret], Contact::getPublicIdByUserId($uid));
1121 }
1122
1123 api_register_func('api/favorites/create', 'api_favorites_create_destroy', true);
1124 api_register_func('api/favorites/destroy', 'api_favorites_create_destroy', true);
1125
1126 /**
1127  * Returns all lists the user subscribes to.
1128  *
1129  * @param string $type Return type (atom, rss, xml, json)
1130  *
1131  * @return array|string
1132  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-list
1133  */
1134 function api_lists_list($type)
1135 {
1136         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1137         $ret = [];
1138         /// @TODO $ret is not filled here?
1139         return DI::apiResponse()->formatData('lists', $type, ["lists_list" => $ret]);
1140 }
1141
1142 api_register_func('api/lists/list', 'api_lists_list', true);
1143 api_register_func('api/lists/subscriptions', 'api_lists_list', true);
1144
1145 /**
1146  * Returns all groups the user owns.
1147  *
1148  * @param string $type Return type (atom, rss, xml, json)
1149  *
1150  * @return array|string
1151  * @throws BadRequestException
1152  * @throws ForbiddenException
1153  * @throws ImagickException
1154  * @throws InternalServerErrorException
1155  * @throws UnauthorizedException
1156  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-ownerships
1157  */
1158 function api_lists_ownerships($type)
1159 {
1160         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1161         $uid = BaseApi::getCurrentUserID();
1162
1163         // params
1164         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1165
1166         $groups = DBA::select('group', [], ['deleted' => 0, 'uid' => $uid]);
1167
1168         // loop through all groups
1169         $lists = [];
1170         foreach ($groups as $group) {
1171                 if ($group['visible']) {
1172                         $mode = 'public';
1173                 } else {
1174                         $mode = 'private';
1175                 }
1176                 $lists[] = [
1177                         'name' => $group['name'],
1178                         'id' => intval($group['id']),
1179                         'id_str' => (string) $group['id'],
1180                         'user' => $user_info,
1181                         'mode' => $mode
1182                 ];
1183         }
1184         return DI::apiResponse()->formatData("lists", $type, ['lists' => ['lists' => $lists]]);
1185 }
1186
1187 api_register_func('api/lists/ownerships', 'api_lists_ownerships', true);
1188
1189 /**
1190  * Returns either the friends of the follower list
1191  *
1192  * Considers friends and followers lists to be private and won't return
1193  * anything if any user_id parameter is passed.
1194  *
1195  * @param string $qtype Either "friends" or "followers"
1196  * @return boolean|array
1197  * @throws BadRequestException
1198  * @throws ForbiddenException
1199  * @throws ImagickException
1200  * @throws InternalServerErrorException
1201  * @throws UnauthorizedException
1202  */
1203 function api_statuses_f($qtype)
1204 {
1205         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1206         $uid = BaseApi::getCurrentUserID();
1207
1208         // pagination
1209         $count = $_GET['count'] ?? 20;
1210         $page = $_GET['page'] ?? 1;
1211
1212         $start = max(0, ($page - 1) * $count);
1213
1214         if (!empty($_GET['cursor']) && $_GET['cursor'] == 'undefined') {
1215                 /* this is to stop Hotot to load friends multiple times
1216                 *  I'm not sure if I'm missing return something or
1217                 *  is a bug in hotot. Workaround, meantime
1218                 */
1219
1220                 /*$ret=Array();
1221                 return array('$users' => $ret);*/
1222                 return false;
1223         }
1224
1225         $sql_extra = '';
1226         if ($qtype == 'friends') {
1227                 $sql_extra = sprintf(" AND ( `rel` = %d OR `rel` = %d ) ", intval(Contact::SHARING), intval(Contact::FRIEND));
1228         } elseif ($qtype == 'followers') {
1229                 $sql_extra = sprintf(" AND ( `rel` = %d OR `rel` = %d ) ", intval(Contact::FOLLOWER), intval(Contact::FRIEND));
1230         }
1231
1232         if ($qtype == 'blocks') {
1233                 $sql_filter = 'AND `blocked` AND NOT `pending`';
1234         } elseif ($qtype == 'incoming') {
1235                 $sql_filter = 'AND `pending`';
1236         } else {
1237                 $sql_filter = 'AND (NOT `blocked` OR `pending`)';
1238         }
1239
1240         // @todo This query most likely can be replaced with a Contact::select...
1241         $r = DBA::toArray(DBA::p(
1242                 "SELECT `id`
1243                 FROM `contact`
1244                 WHERE `uid` = ?
1245                 AND NOT `self`
1246                 $sql_filter
1247                 $sql_extra
1248                 ORDER BY `nick`
1249                 LIMIT ?, ?",
1250                 $uid,
1251                 $start,
1252                 $count
1253         ));
1254
1255         $ret = [];
1256         foreach ($r as $cid) {
1257                 $user = DI::twitterUser()->createFromContactId($cid['id'], $uid, false)->toArray();
1258                 // "uid" is only needed for some internal stuff, so remove it from here
1259                 unset($user['uid']);
1260
1261                 if ($user) {
1262                         $ret[] = $user;
1263                 }
1264         }
1265
1266         return ['user' => $ret];
1267 }
1268
1269 /**
1270  * Returns the list of friends of the provided user
1271  *
1272  * @deprecated By Twitter API in favor of friends/list
1273  *
1274  * @param string $type Either "json" or "xml"
1275  * @return boolean|string|array
1276  * @throws BadRequestException
1277  * @throws ForbiddenException
1278  */
1279 function api_statuses_friends($type)
1280 {
1281         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1282         $data =  api_statuses_f("friends");
1283         if ($data === false) {
1284                 return false;
1285         }
1286         return DI::apiResponse()->formatData("users", $type, $data);
1287 }
1288
1289 api_register_func('api/statuses/friends', 'api_statuses_friends', true);
1290
1291 /**
1292  * Returns the list of followers of the provided user
1293  *
1294  * @deprecated By Twitter API in favor of friends/list
1295  *
1296  * @param string $type Either "json" or "xml"
1297  * @return boolean|string|array
1298  * @throws BadRequestException
1299  * @throws ForbiddenException
1300  */
1301 function api_statuses_followers($type)
1302 {
1303         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1304         $data = api_statuses_f("followers");
1305         if ($data === false) {
1306                 return false;
1307         }
1308         return DI::apiResponse()->formatData("users", $type, $data);
1309 }
1310
1311 api_register_func('api/statuses/followers', 'api_statuses_followers', true);
1312
1313 /**
1314  * Returns the list of blocked users
1315  *
1316  * @see https://developer.twitter.com/en/docs/accounts-and-users/mute-block-report-users/api-reference/get-blocks-list
1317  *
1318  * @param string $type Either "json" or "xml"
1319  *
1320  * @return boolean|string|array
1321  * @throws BadRequestException
1322  * @throws ForbiddenException
1323  */
1324 function api_blocks_list($type)
1325 {
1326         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1327         $data =  api_statuses_f('blocks');
1328         if ($data === false) {
1329                 return false;
1330         }
1331         return DI::apiResponse()->formatData("users", $type, $data);
1332 }
1333
1334 api_register_func('api/blocks/list', 'api_blocks_list', true);
1335
1336 /**
1337  * Returns the list of pending users IDs
1338  *
1339  * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-incoming
1340  *
1341  * @param string $type Either "json" or "xml"
1342  *
1343  * @return boolean|string|array
1344  * @throws BadRequestException
1345  * @throws ForbiddenException
1346  */
1347 function api_friendships_incoming($type)
1348 {
1349         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1350         $data =  api_statuses_f('incoming');
1351         if ($data === false) {
1352                 return false;
1353         }
1354
1355         $ids = [];
1356         foreach ($data['user'] as $user) {
1357                 $ids[] = $user['id'];
1358         }
1359
1360         return DI::apiResponse()->formatData("ids", $type, ['id' => $ids]);
1361 }
1362
1363 api_register_func('api/friendships/incoming', 'api_friendships_incoming', true);
1364
1365 /**
1366  * Sends a new direct message.
1367  *
1368  * @param string $type Return type (atom, rss, xml, json)
1369  *
1370  * @return array|string
1371  * @throws BadRequestException
1372  * @throws ForbiddenException
1373  * @throws ImagickException
1374  * @throws InternalServerErrorException
1375  * @throws NotFoundException
1376  * @throws UnauthorizedException
1377  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-message
1378  */
1379 function api_direct_messages_new($type)
1380 {
1381         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1382         $uid = BaseApi::getCurrentUserID();
1383
1384         if (empty($_POST["text"]) || empty($_POST['screen_name']) && empty($_POST['user_id'])) {
1385                 return;
1386         }
1387
1388         $sender = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1389
1390         $cid = BaseApi::getContactIDForSearchterm($_REQUEST['screen_name'] ?? '', $_REQUEST['profileurl'] ?? '', $_REQUEST['user_id'] ?? 0, $uid);
1391         if (empty($cid)) {
1392                 throw new NotFoundException('Recipient not found');
1393         }
1394
1395         $replyto = '';
1396         if (!empty($_REQUEST['replyto'])) {
1397                 $mail    = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uid' => $uid, 'id' => $_REQUEST['replyto']]);
1398                 $replyto = $mail['parent-uri'];
1399                 $sub     = $mail['title'];
1400         } else {
1401                 if (!empty($_REQUEST['title'])) {
1402                         $sub = $_REQUEST['title'];
1403                 } else {
1404                         $sub = ((strlen($_POST['text'])>10) ? substr($_POST['text'], 0, 10)."...":$_POST['text']);
1405                 }
1406         }
1407
1408         $cdata = Contact::getPublicAndUserContactID($cid, $uid);
1409
1410         $id = Mail::send($cdata['user'], $_POST['text'], $sub, $replyto);
1411
1412         if ($id > -1) {
1413                 $mail = DBA::selectFirst('mail', [], ['id' => $id]);
1414                 $ret = api_format_messages($mail, DI::twitterUser()->createFromContactId($cid, $uid, true)->toArray(), $sender);
1415         } else {
1416                 $ret = ["error" => $id];
1417         }
1418
1419         return DI::apiResponse()->formatData("direct-messages", $type, ['direct_message' => $ret], Contact::getPublicIdByUserId($uid));
1420 }
1421
1422 api_register_func('api/direct_messages/new', 'api_direct_messages_new', true);
1423
1424 /**
1425  * delete a direct_message from mail table through api
1426  *
1427  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1428  * @return string|array
1429  * @throws BadRequestException
1430  * @throws ForbiddenException
1431  * @throws ImagickException
1432  * @throws InternalServerErrorException
1433  * @throws UnauthorizedException
1434  * @see   https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/delete-message
1435  */
1436 function api_direct_messages_destroy($type)
1437 {
1438         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1439         $uid = BaseApi::getCurrentUserID();
1440
1441         //required
1442         $id = $_REQUEST['id'] ?? 0;
1443         // optional
1444         $parenturi = $_REQUEST['friendica_parenturi'] ?? '';
1445         $verbose = (!empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false");
1446         /// @todo optional parameter 'include_entities' from Twitter API not yet implemented
1447
1448         // error if no id or parenturi specified (for clients posting parent-uri as well)
1449         if ($verbose == "true" && ($id == 0 || $parenturi == "")) {
1450                 $answer = ['result' => 'error', 'message' => 'message id or parenturi not specified'];
1451                 return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1452         }
1453
1454         // BadRequestException if no id specified (for clients using Twitter API)
1455         if ($id == 0) {
1456                 throw new BadRequestException('Message id not specified');
1457         }
1458
1459         // add parent-uri to sql command if specified by calling app
1460         $sql_extra = ($parenturi != "" ? " AND `parent-uri` = '" . DBA::escape($parenturi) . "'" : "");
1461
1462         // error message if specified id is not in database
1463         if (!DBA::exists('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id])) {
1464                 if ($verbose == "true") {
1465                         $answer = ['result' => 'error', 'message' => 'message id not in database'];
1466                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1467                 }
1468                 /// @todo BadRequestException ok for Twitter API clients?
1469                 throw new BadRequestException('message id not in database');
1470         }
1471
1472         // delete message
1473         $result = DBA::delete('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id]);
1474
1475         if ($verbose == "true") {
1476                 if ($result) {
1477                         // return success
1478                         $answer = ['result' => 'ok', 'message' => 'message deleted'];
1479                         return DI::apiResponse()->formatData("direct_message_delete", $type, ['$result' => $answer]);
1480                 } else {
1481                         $answer = ['result' => 'error', 'message' => 'unknown error'];
1482                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1483                 }
1484         }
1485         /// @todo return JSON data like Twitter API not yet implemented
1486 }
1487
1488 api_register_func('api/direct_messages/destroy', 'api_direct_messages_destroy', true);
1489
1490 /**
1491  * Unfollow Contact
1492  *
1493  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1494  * @return string|array
1495  * @throws HTTPException\BadRequestException
1496  * @throws HTTPException\ExpectationFailedException
1497  * @throws HTTPException\ForbiddenException
1498  * @throws HTTPException\InternalServerErrorException
1499  * @throws HTTPException\NotFoundException
1500  * @see   https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy.html
1501  */
1502 function api_friendships_destroy($type)
1503 {
1504         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1505         $uid = BaseApi::getCurrentUserID();
1506
1507         $owner = User::getOwnerDataById($uid);
1508         if (!$owner) {
1509                 Logger::notice(BaseApi::LOG_PREFIX . 'No owner {uid} found', ['module' => 'api', 'action' => 'friendships_destroy', 'uid' => $uid]);
1510                 throw new HTTPException\NotFoundException('Error Processing Request');
1511         }
1512
1513         $contact_id = $_REQUEST['user_id'] ?? 0;
1514
1515         if (empty($contact_id)) {
1516                 Logger::notice(BaseApi::LOG_PREFIX . 'No user_id specified', ['module' => 'api', 'action' => 'friendships_destroy']);
1517                 throw new HTTPException\BadRequestException('no user_id specified');
1518         }
1519
1520         // Get Contact by given id
1521         $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => 0, 'self' => false]);
1522
1523         if(!DBA::isResult($contact)) {
1524                 Logger::notice(BaseApi::LOG_PREFIX . 'No public contact found for ID {contact}', ['module' => 'api', 'action' => 'friendships_destroy', 'contact' => $contact_id]);
1525                 throw new HTTPException\NotFoundException('no contact found to given ID');
1526         }
1527
1528         $url = $contact['url'];
1529
1530         $condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)",
1531                         $uid, Contact::SHARING, Contact::FRIEND, Strings::normaliseLink($url),
1532                         Strings::normaliseLink($url), $url];
1533         $contact = DBA::selectFirst('contact', [], $condition);
1534
1535         if (!DBA::isResult($contact)) {
1536                 Logger::notice(BaseApi::LOG_PREFIX . 'Not following contact', ['module' => 'api', 'action' => 'friendships_destroy']);
1537                 throw new HTTPException\NotFoundException('Not following Contact');
1538         }
1539
1540         try {
1541                 $result = Contact::terminateFriendship($owner, $contact);
1542
1543                 if ($result === null) {
1544                         Logger::notice(BaseApi::LOG_PREFIX . 'Not supported for {network}', ['module' => 'api', 'action' => 'friendships_destroy', 'network' => $contact['network']]);
1545                         throw new HTTPException\ExpectationFailedException('Unfollowing is currently not supported by this contact\'s network.');
1546                 }
1547
1548                 if ($result === false) {
1549                         throw new HTTPException\ServiceUnavailableException('Unable to unfollow this contact, please retry in a few minutes or contact your administrator.');
1550                 }
1551         } catch (Exception $e) {
1552                 Logger::error(BaseApi::LOG_PREFIX . $e->getMessage(), ['owner' => $owner, 'contact' => $contact]);
1553                 throw new HTTPException\InternalServerErrorException('Unable to unfollow this contact, please contact your administrator');
1554         }
1555
1556         // "uid" is only needed for some internal stuff, so remove it from here
1557         unset($contact['uid']);
1558
1559         // Set screen_name since Twidere requests it
1560         $contact['screen_name'] = $contact['nick'];
1561
1562         return DI::apiResponse()->formatData('friendships-destroy', $type, ['user' => $contact]);
1563 }
1564
1565 api_register_func('api/friendships/destroy', 'api_friendships_destroy', true);
1566
1567 /**
1568  *
1569  * @param string $type Return type (atom, rss, xml, json)
1570  * @param string $box
1571  * @param string $verbose
1572  *
1573  * @return array|string
1574  * @throws BadRequestException
1575  * @throws ForbiddenException
1576  * @throws ImagickException
1577  * @throws InternalServerErrorException
1578  * @throws UnauthorizedException
1579  */
1580 function api_direct_messages_box($type, $box, $verbose)
1581 {
1582         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1583         $uid = BaseApi::getCurrentUserID();
1584
1585         // params
1586         $count = $_GET['count'] ?? 20;
1587         $page = $_REQUEST['page'] ?? 1;
1588
1589         $since_id = $_REQUEST['since_id'] ?? 0;
1590         $max_id = $_REQUEST['max_id'] ?? 0;
1591
1592         $user_id = $_REQUEST['user_id'] ?? '';
1593         $screen_name = $_REQUEST['screen_name'] ?? '';
1594
1595         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1596
1597         $profile_url = $user_info["url"];
1598
1599         // pagination
1600         $start = max(0, ($page - 1) * $count);
1601
1602         $sql_extra = "";
1603
1604         // filters
1605         if ($box=="sentbox") {
1606                 $sql_extra = "`mail`.`from-url`='" . DBA::escape($profile_url) . "'";
1607         } elseif ($box == "conversation") {
1608                 $sql_extra = "`mail`.`parent-uri`='" . DBA::escape($_GET['uri'] ?? '')  . "'";
1609         } elseif ($box == "all") {
1610                 $sql_extra = "true";
1611         } elseif ($box == "inbox") {
1612                 $sql_extra = "`mail`.`from-url`!='" . DBA::escape($profile_url) . "'";
1613         }
1614
1615         if ($max_id > 0) {
1616                 $sql_extra .= ' AND `mail`.`id` <= ' . intval($max_id);
1617         }
1618
1619         if ($user_id != "") {
1620                 $sql_extra .= ' AND `mail`.`contact-id` = ' . intval($user_id);
1621         } elseif ($screen_name !="") {
1622                 $sql_extra .= " AND `contact`.`nick` = '" . DBA::escape($screen_name). "'";
1623         }
1624
1625         $r = DBA::toArray(DBA::p(
1626                 "SELECT `mail`.*, `contact`.`nurl` AS `contact-url` FROM `mail`,`contact` WHERE `mail`.`contact-id` = `contact`.`id` AND `mail`.`uid` = ? AND $sql_extra AND `mail`.`id` > ? ORDER BY `mail`.`id` DESC LIMIT ?,?",
1627                 $uid,
1628                 $since_id,
1629                 $start,
1630                 $count
1631         ));
1632         if ($verbose == "true" && !DBA::isResult($r)) {
1633                 $answer = ['result' => 'error', 'message' => 'no mails available'];
1634                 return DI::apiResponse()->formatData("direct_messages_all", $type, ['$result' => $answer]);
1635         }
1636
1637         $ret = [];
1638         foreach ($r as $item) {
1639                 if ($box == "inbox" || $item['from-url'] != $profile_url) {
1640                         $recipient = $user_info;
1641                         $sender = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
1642                 } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
1643                         $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
1644                         $sender = $user_info;
1645                 }
1646
1647                 if (isset($recipient) && isset($sender)) {
1648                         $ret[] = api_format_messages($item, $recipient, $sender);
1649                 }
1650         }
1651
1652         return DI::apiResponse()->formatData("direct-messages", $type, ['direct_message' => $ret], Contact::getPublicIdByUserId($uid));
1653 }
1654
1655 /**
1656  * Returns the most recent direct messages sent by the user.
1657  *
1658  * @param string $type Return type (atom, rss, xml, json)
1659  *
1660  * @return array|string
1661  * @throws BadRequestException
1662  * @throws ForbiddenException
1663  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-sent-message
1664  */
1665 function api_direct_messages_sentbox($type)
1666 {
1667         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1668         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1669         return api_direct_messages_box($type, "sentbox", $verbose);
1670 }
1671
1672 api_register_func('api/direct_messages/sent', 'api_direct_messages_sentbox', true);
1673
1674 /**
1675  * Returns the most recent direct messages sent to the user.
1676  *
1677  * @param string $type Return type (atom, rss, xml, json)
1678  *
1679  * @return array|string
1680  * @throws BadRequestException
1681  * @throws ForbiddenException
1682  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-messages
1683  */
1684 function api_direct_messages_inbox($type)
1685 {
1686         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1687         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1688         return api_direct_messages_box($type, "inbox", $verbose);
1689 }
1690
1691 api_register_func('api/direct_messages', 'api_direct_messages_inbox', true);
1692
1693 /**
1694  *
1695  * @param string $type Return type (atom, rss, xml, json)
1696  *
1697  * @return array|string
1698  * @throws BadRequestException
1699  * @throws ForbiddenException
1700  */
1701 function api_direct_messages_all($type)
1702 {
1703         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1704         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1705         return api_direct_messages_box($type, "all", $verbose);
1706 }
1707
1708 api_register_func('api/direct_messages/all', 'api_direct_messages_all', true);
1709
1710 /**
1711  *
1712  * @param string $type Return type (atom, rss, xml, json)
1713  *
1714  * @return array|string
1715  * @throws BadRequestException
1716  * @throws ForbiddenException
1717  */
1718 function api_direct_messages_conversation($type)
1719 {
1720         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1721         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1722         return api_direct_messages_box($type, "conversation", $verbose);
1723 }
1724
1725 api_register_func('api/direct_messages/conversation', 'api_direct_messages_conversation', true);
1726
1727 /**
1728  * list all photos of the authenticated user
1729  *
1730  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1731  * @return string|array
1732  * @throws ForbiddenException
1733  * @throws InternalServerErrorException
1734  */
1735 function api_fr_photos_list($type)
1736 {
1737         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1738         $uid = BaseApi::getCurrentUserID();
1739
1740         $r = DBA::toArray(DBA::p(
1741                 "SELECT `resource-id`, MAX(scale) AS `scale`, `album`, `filename`, `type`, MAX(`created`) AS `created`,
1742                 MAX(`edited`) AS `edited`, MAX(`desc`) AS `desc` FROM `photo`
1743                 WHERE `uid` = ? AND NOT `photo-type` IN (?, ?) GROUP BY `resource-id`, `album`, `filename`, `type`",
1744                 $uid, Photo::CONTACT_AVATAR, Photo::CONTACT_BANNER
1745         ));
1746         $typetoext = [
1747                 'image/jpeg' => 'jpg',
1748                 'image/png' => 'png',
1749                 'image/gif' => 'gif'
1750         ];
1751         $data = ['photo'=>[]];
1752         if (DBA::isResult($r)) {
1753                 foreach ($r as $rr) {
1754                         $photo = [];
1755                         $photo['id'] = $rr['resource-id'];
1756                         $photo['album'] = $rr['album'];
1757                         $photo['filename'] = $rr['filename'];
1758                         $photo['type'] = $rr['type'];
1759                         $thumb = DI::baseUrl() . "/photo/" . $rr['resource-id'] . "-" . $rr['scale'] . "." . $typetoext[$rr['type']];
1760                         $photo['created'] = $rr['created'];
1761                         $photo['edited'] = $rr['edited'];
1762                         $photo['desc'] = $rr['desc'];
1763
1764                         if ($type == "xml") {
1765                                 $data['photo'][] = ["@attributes" => $photo, "1" => $thumb];
1766                         } else {
1767                                 $photo['thumb'] = $thumb;
1768                                 $data['photo'][] = $photo;
1769                         }
1770                 }
1771         }
1772         return DI::apiResponse()->formatData("photos", $type, $data);
1773 }
1774
1775 api_register_func('api/friendica/photos/list', 'api_fr_photos_list', true);
1776
1777 /**
1778  * upload a new photo or change an existing photo
1779  *
1780  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1781  * @return string|array
1782  * @throws BadRequestException
1783  * @throws ForbiddenException
1784  * @throws ImagickException
1785  * @throws InternalServerErrorException
1786  * @throws NotFoundException
1787  */
1788 function api_fr_photo_create_update($type)
1789 {
1790         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1791         $uid = BaseApi::getCurrentUserID();
1792
1793         // input params
1794         $photo_id  = $_REQUEST['photo_id']  ?? null;
1795         $desc      = $_REQUEST['desc']      ?? null;
1796         $album     = $_REQUEST['album']     ?? null;
1797         $album_new = $_REQUEST['album_new'] ?? null;
1798         $allow_cid = $_REQUEST['allow_cid'] ?? null;
1799         $deny_cid  = $_REQUEST['deny_cid' ] ?? null;
1800         $allow_gid = $_REQUEST['allow_gid'] ?? null;
1801         $deny_gid  = $_REQUEST['deny_gid' ] ?? null;
1802         $visibility = !$allow_cid && !$deny_cid && !$allow_gid && !$deny_gid;
1803
1804         // do several checks on input parameters
1805         // we do not allow calls without album string
1806         if ($album == null) {
1807                 throw new BadRequestException("no albumname specified");
1808         }
1809         // if photo_id == null --> we are uploading a new photo
1810         if ($photo_id == null) {
1811                 $mode = "create";
1812
1813                 // error if no media posted in create-mode
1814                 if (empty($_FILES['media'])) {
1815                         // Output error
1816                         throw new BadRequestException("no media data submitted");
1817                 }
1818
1819                 // album_new will be ignored in create-mode
1820                 $album_new = "";
1821         } else {
1822                 $mode = "update";
1823
1824                 // check if photo is existing in databasei
1825                 if (!Photo::exists(['resource-id' => $photo_id, 'uid' => $uid, 'album' => $album])) {
1826                         throw new BadRequestException("photo not available");
1827                 }
1828         }
1829
1830         // checks on acl strings provided by clients
1831         $acl_input_error = false;
1832         $acl_input_error |= check_acl_input($allow_cid, $uid);
1833         $acl_input_error |= check_acl_input($deny_cid, $uid);
1834         $acl_input_error |= check_acl_input($allow_gid, $uid);
1835         $acl_input_error |= check_acl_input($deny_gid, $uid);
1836         if ($acl_input_error) {
1837                 throw new BadRequestException("acl data invalid");
1838         }
1839         // now let's upload the new media in create-mode
1840         if ($mode == "create") {
1841                 $media = $_FILES['media'];
1842                 $data = save_media_to_database("photo", $media, $type, $album, trim($allow_cid), trim($deny_cid), trim($allow_gid), trim($deny_gid), $desc, Photo::DEFAULT, $visibility, null, $uid);
1843
1844                 // return success of updating or error message
1845                 if (!is_null($data)) {
1846                         return DI::apiResponse()->formatData("photo_create", $type, $data);
1847                 } else {
1848                         throw new InternalServerErrorException("unknown error - uploading photo failed, see Friendica log for more information");
1849                 }
1850         }
1851
1852         // now let's do the changes in update-mode
1853         if ($mode == "update") {
1854                 $updated_fields = [];
1855
1856                 if (!is_null($desc)) {
1857                         $updated_fields['desc'] = $desc;
1858                 }
1859
1860                 if (!is_null($album_new)) {
1861                         $updated_fields['album'] = $album_new;
1862                 }
1863
1864                 if (!is_null($allow_cid)) {
1865                         $allow_cid = trim($allow_cid);
1866                         $updated_fields['allow_cid'] = $allow_cid;
1867                 }
1868
1869                 if (!is_null($deny_cid)) {
1870                         $deny_cid = trim($deny_cid);
1871                         $updated_fields['deny_cid'] = $deny_cid;
1872                 }
1873
1874                 if (!is_null($allow_gid)) {
1875                         $allow_gid = trim($allow_gid);
1876                         $updated_fields['allow_gid'] = $allow_gid;
1877                 }
1878
1879                 if (!is_null($deny_gid)) {
1880                         $deny_gid = trim($deny_gid);
1881                         $updated_fields['deny_gid'] = $deny_gid;
1882                 }
1883
1884                 $result = false;
1885                 if (count($updated_fields) > 0) {
1886                         $nothingtodo = false;
1887                         $result = Photo::update($updated_fields, ['uid' => $uid, 'resource-id' => $photo_id, 'album' => $album]);
1888                 } else {
1889                         $nothingtodo = true;
1890                 }
1891
1892                 if (!empty($_FILES['media'])) {
1893                         $nothingtodo = false;
1894                         $media = $_FILES['media'];
1895                         $data = save_media_to_database("photo", $media, $type, $album, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $desc, Photo::DEFAULT, $visibility, $photo_id, $uid);
1896                         if (!is_null($data)) {
1897                                 return DI::apiResponse()->formatData("photo_update", $type, $data);
1898                         }
1899                 }
1900
1901                 // return success of updating or error message
1902                 if ($result) {
1903                         $answer = ['result' => 'updated', 'message' => 'Image id `' . $photo_id . '` has been updated.'];
1904                         return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
1905                 } else {
1906                         if ($nothingtodo) {
1907                                 $answer = ['result' => 'cancelled', 'message' => 'Nothing to update for image id `' . $photo_id . '`.'];
1908                                 return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
1909                         }
1910                         throw new InternalServerErrorException("unknown error - update photo entry in database failed");
1911                 }
1912         }
1913         throw new InternalServerErrorException("unknown error - this error on uploading or updating a photo should never happen");
1914 }
1915
1916 api_register_func('api/friendica/photo/create', 'api_fr_photo_create_update', true);
1917 api_register_func('api/friendica/photo/update', 'api_fr_photo_create_update', true);
1918
1919 /**
1920  * returns the details of a specified photo id, if scale is given, returns the photo data in base 64
1921  *
1922  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1923  * @return string|array
1924  * @throws BadRequestException
1925  * @throws ForbiddenException
1926  * @throws InternalServerErrorException
1927  * @throws NotFoundException
1928  */
1929 function api_fr_photo_detail($type)
1930 {
1931         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1932         $uid = BaseApi::getCurrentUserID();
1933
1934         if (empty($_REQUEST['photo_id'])) {
1935                 throw new BadRequestException("No photo id.");
1936         }
1937
1938         $scale = (!empty($_REQUEST['scale']) ? intval($_REQUEST['scale']) : false);
1939         $photo_id = $_REQUEST['photo_id'];
1940
1941         // prepare json/xml output with data from database for the requested photo
1942         $data = prepare_photo_data($type, $scale, $photo_id, $uid);
1943
1944         return DI::apiResponse()->formatData("photo_detail", $type, $data);
1945 }
1946
1947 api_register_func('api/friendica/photo', 'api_fr_photo_detail', true);
1948
1949 /**
1950  * updates the profile image for the user (either a specified profile or the default profile)
1951  *
1952  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1953  *
1954  * @return string|array
1955  * @throws BadRequestException
1956  * @throws ForbiddenException
1957  * @throws ImagickException
1958  * @throws InternalServerErrorException
1959  * @throws NotFoundException
1960  * @see   https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image
1961  */
1962 function api_account_update_profile_image($type)
1963 {
1964         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1965         $uid = BaseApi::getCurrentUserID();
1966
1967         // input params
1968         $profile_id = $_REQUEST['profile_id'] ?? 0;
1969
1970         // error if image data is missing
1971         if (empty($_FILES['image'])) {
1972                 throw new BadRequestException("no media data submitted");
1973         }
1974
1975         // check if specified profile id is valid
1976         if ($profile_id != 0) {
1977                 $profile = DBA::selectFirst('profile', ['is-default'], ['uid' => $uid, 'id' => $profile_id]);
1978                 // error message if specified profile id is not in database
1979                 if (!DBA::isResult($profile)) {
1980                         throw new BadRequestException("profile_id not available");
1981                 }
1982                 $is_default_profile = $profile['is-default'];
1983         } else {
1984                 $is_default_profile = 1;
1985         }
1986
1987         // get mediadata from image or media (Twitter call api/account/update_profile_image provides image)
1988         $media = null;
1989         if (!empty($_FILES['image'])) {
1990                 $media = $_FILES['image'];
1991         } elseif (!empty($_FILES['media'])) {
1992                 $media = $_FILES['media'];
1993         }
1994         // save new profile image
1995         $data = save_media_to_database("profileimage", $media, $type, DI::l10n()->t(Photo::PROFILE_PHOTOS), "", "", "", "", "", Photo::USER_AVATAR, false, null, $uid);
1996
1997         // get filetype
1998         if (is_array($media['type'])) {
1999                 $filetype = $media['type'][0];
2000         } else {
2001                 $filetype = $media['type'];
2002         }
2003         if ($filetype == "image/jpeg") {
2004                 $fileext = "jpg";
2005         } elseif ($filetype == "image/png") {
2006                 $fileext = "png";
2007         } else {
2008                 throw new InternalServerErrorException('Unsupported filetype');
2009         }
2010
2011         // change specified profile or all profiles to the new resource-id
2012         if ($is_default_profile) {
2013                 $condition = ["`profile` AND `resource-id` != ? AND `uid` = ?", $data['photo']['id'], $uid];
2014                 Photo::update(['profile' => false, 'photo-type' => Photo::DEFAULT], $condition);
2015         } else {
2016                 $fields = ['photo' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-4.' . $fileext,
2017                         'thumb' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-5.' . $fileext];
2018                 DBA::update('profile', $fields, ['id' => $_REQUEST['profile'], 'uid' => $uid]);
2019         }
2020
2021         Contact::updateSelfFromUserID($uid, true);
2022
2023         // Update global directory in background
2024         Profile::publishUpdate($uid);
2025
2026         // output for client
2027         if ($data) {
2028                 $skip_status = $_REQUEST['skip_status'] ?? false;
2029
2030                 $user_info = DI::twitterUser()->createFromUserId($uid, $skip_status)->toArray();
2031
2032                 // "verified" isn't used here in the standard
2033                 unset($user_info["verified"]);
2034
2035                 // "uid" is only needed for some internal stuff, so remove it from here
2036                 unset($user_info['uid']);
2037
2038                 return DI::apiResponse()->formatData("user", $type, ['user' => $user_info]);
2039         } else {
2040                 // SaveMediaToDatabase failed for some reason
2041                 throw new InternalServerErrorException("image upload failed");
2042         }
2043 }
2044
2045 api_register_func('api/account/update_profile_image', 'api_account_update_profile_image', true);
2046
2047 /**
2048  * Return all or a specified group of the user with the containing contacts.
2049  *
2050  * @param string $type Return type (atom, rss, xml, json)
2051  *
2052  * @return array|string
2053  * @throws BadRequestException
2054  * @throws ForbiddenException
2055  * @throws ImagickException
2056  * @throws InternalServerErrorException
2057  * @throws UnauthorizedException
2058  */
2059 function api_friendica_group_show($type)
2060 {
2061         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2062         $uid = BaseApi::getCurrentUserID();
2063
2064         // params
2065         $gid = $_REQUEST['gid'] ?? 0;
2066
2067         // get data of the specified group id or all groups if not specified
2068         if ($gid != 0) {
2069                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid, 'id' => $gid]);
2070
2071                 // error message if specified gid is not in database
2072                 if (!DBA::isResult($groups)) {
2073                         throw new BadRequestException("gid not available");
2074                 }
2075         } else {
2076                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid]);
2077         }
2078
2079         // loop through all groups and retrieve all members for adding data in the user array
2080         $grps = [];
2081         foreach ($groups as $rr) {
2082                 $members = Contact\Group::getById($rr['id']);
2083                 $users = [];
2084
2085                 if ($type == "xml") {
2086                         $user_element = "users";
2087                         $k = 0;
2088                         foreach ($members as $member) {
2089                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], $uid, true)->toArray();
2090                                 $users[$k++.":user"] = $user;
2091                         }
2092                 } else {
2093                         $user_element = "user";
2094                         foreach ($members as $member) {
2095                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], $uid, true)->toArray();
2096                                 $users[] = $user;
2097                         }
2098                 }
2099                 $grps[] = ['name' => $rr['name'], 'gid' => $rr['id'], $user_element => $users];
2100         }
2101         return DI::apiResponse()->formatData("groups", $type, ['group' => $grps]);
2102 }
2103
2104 api_register_func('api/friendica/group_show', 'api_friendica_group_show', true);
2105
2106 /**
2107  * Delete a group.
2108  *
2109  * @param string $type Return type (atom, rss, xml, json)
2110  *
2111  * @return array|string
2112  * @throws BadRequestException
2113  * @throws ForbiddenException
2114  * @throws ImagickException
2115  * @throws InternalServerErrorException
2116  * @throws UnauthorizedException
2117  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-destroy
2118  */
2119 function api_lists_destroy($type)
2120 {
2121         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2122         $uid = BaseApi::getCurrentUserID();
2123
2124         // params
2125         $gid = $_REQUEST['list_id'] ?? 0;
2126
2127         // error if no gid specified
2128         if ($gid == 0) {
2129                 throw new BadRequestException('gid not specified');
2130         }
2131
2132         // get data of the specified group id
2133         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
2134         // error message if specified gid is not in database
2135         if (!$group) {
2136                 throw new BadRequestException('gid not available');
2137         }
2138
2139         if (Group::remove($gid)) {
2140                 $list = [
2141                         'name' => $group['name'],
2142                         'id' => intval($gid),
2143                         'id_str' => (string) $gid,
2144                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
2145                 ];
2146
2147                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
2148         }
2149 }
2150
2151 api_register_func('api/lists/destroy', 'api_lists_destroy', true);
2152
2153 /**
2154  * Create the specified group with the posted array of contacts.
2155  *
2156  * @param string $type Return type (atom, rss, xml, json)
2157  *
2158  * @return array|string
2159  * @throws BadRequestException
2160  * @throws ForbiddenException
2161  * @throws ImagickException
2162  * @throws InternalServerErrorException
2163  * @throws UnauthorizedException
2164  */
2165 function api_friendica_group_create($type)
2166 {
2167         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2168         $uid = BaseApi::getCurrentUserID();
2169
2170         // params
2171         $name = $_REQUEST['name'] ?? '';
2172         $json = json_decode($_POST['json'], true);
2173         $users = $json['user'];
2174
2175         $success = group_create($name, $uid, $users);
2176
2177         return DI::apiResponse()->formatData("group_create", $type, ['result' => $success]);
2178 }
2179
2180 api_register_func('api/friendica/group_create', 'api_friendica_group_create', true);
2181
2182 /**
2183  * Create a new group.
2184  *
2185  * @param string $type Return type (atom, rss, xml, json)
2186  *
2187  * @return array|string
2188  * @throws BadRequestException
2189  * @throws ForbiddenException
2190  * @throws ImagickException
2191  * @throws InternalServerErrorException
2192  * @throws UnauthorizedException
2193  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-create
2194  */
2195 function api_lists_create($type)
2196 {
2197         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2198         $uid = BaseApi::getCurrentUserID();
2199
2200         // params
2201         $name = $_REQUEST['name'] ?? '';
2202
2203         $success = group_create($name, $uid);
2204         if ($success['success']) {
2205                 $grp = [
2206                         'name' => $success['name'],
2207                         'id' => intval($success['gid']),
2208                         'id_str' => (string) $success['gid'],
2209                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
2210                 ];
2211
2212                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $grp]);
2213         }
2214 }
2215
2216 api_register_func('api/lists/create', 'api_lists_create', true);
2217
2218 /**
2219  * Update the specified group with the posted array of contacts.
2220  *
2221  * @param string $type Return type (atom, rss, xml, json)
2222  *
2223  * @return array|string
2224  * @throws BadRequestException
2225  * @throws ForbiddenException
2226  * @throws ImagickException
2227  * @throws InternalServerErrorException
2228  * @throws UnauthorizedException
2229  */
2230 function api_friendica_group_update($type)
2231 {
2232         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2233         $uid = BaseApi::getCurrentUserID();
2234
2235         // params
2236         $gid = $_REQUEST['gid'] ?? 0;
2237         $name = $_REQUEST['name'] ?? '';
2238         $json = json_decode($_POST['json'], true);
2239         $users = $json['user'];
2240
2241         // error if no name specified
2242         if ($name == "") {
2243                 throw new BadRequestException('group name not specified');
2244         }
2245
2246         // error if no gid specified
2247         if ($gid == "") {
2248                 throw new BadRequestException('gid not specified');
2249         }
2250
2251         // remove members
2252         $members = Contact\Group::getById($gid);
2253         foreach ($members as $member) {
2254                 $cid = $member['id'];
2255                 foreach ($users as $user) {
2256                         $found = ($user['cid'] == $cid ? true : false);
2257                 }
2258                 if (!isset($found) || !$found) {
2259                         $gid = Group::getIdByName($uid, $name);
2260                         Group::removeMember($gid, $cid);
2261                 }
2262         }
2263
2264         // add members
2265         $erroraddinguser = false;
2266         $errorusers = [];
2267         foreach ($users as $user) {
2268                 $cid = $user['cid'];
2269
2270                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
2271                         Group::addMember($gid, $cid);
2272                 } else {
2273                         $erroraddinguser = true;
2274                         $errorusers[] = $cid;
2275                 }
2276         }
2277
2278         // return success message incl. missing users in array
2279         $status = ($erroraddinguser ? "missing user" : "ok");
2280         $success = ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
2281         return DI::apiResponse()->formatData("group_update", $type, ['result' => $success]);
2282 }
2283
2284 api_register_func('api/friendica/group_update', 'api_friendica_group_update', true);
2285
2286 /**
2287  * Update information about a group.
2288  *
2289  * @param string $type Return type (atom, rss, xml, json)
2290  *
2291  * @return array|string
2292  * @throws BadRequestException
2293  * @throws ForbiddenException
2294  * @throws ImagickException
2295  * @throws InternalServerErrorException
2296  * @throws UnauthorizedException
2297  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-update
2298  */
2299 function api_lists_update($type)
2300 {
2301         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2302         $uid = BaseApi::getCurrentUserID();
2303
2304         // params
2305         $gid = $_REQUEST['list_id'] ?? 0;
2306         $name = $_REQUEST['name'] ?? '';
2307
2308         // error if no gid specified
2309         if ($gid == 0) {
2310                 throw new BadRequestException('gid not specified');
2311         }
2312
2313         // get data of the specified group id
2314         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
2315         // error message if specified gid is not in database
2316         if (!$group) {
2317                 throw new BadRequestException('gid not available');
2318         }
2319
2320         if (Group::update($gid, $name)) {
2321                 $list = [
2322                         'name' => $name,
2323                         'id' => intval($gid),
2324                         'id_str' => (string) $gid,
2325                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
2326                 ];
2327
2328                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
2329         }
2330 }
2331
2332 api_register_func('api/lists/update', 'api_lists_update', true);
2333
2334 /**
2335  * search for direct_messages containing a searchstring through api
2336  *
2337  * @param string $type      Known types are 'atom', 'rss', 'xml' and 'json'
2338  * @param string $box
2339  * @return string|array (success: success=true if found and search_result contains found messages,
2340  *                          success=false if nothing was found, search_result='nothing found',
2341  *                          error: result=error with error message)
2342  * @throws BadRequestException
2343  * @throws ForbiddenException
2344  * @throws ImagickException
2345  * @throws InternalServerErrorException
2346  * @throws UnauthorizedException
2347  */
2348 function api_friendica_direct_messages_search($type, $box = "")
2349 {
2350         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2351         $uid = BaseApi::getCurrentUserID();
2352
2353         // params
2354         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
2355         $searchstring = $_REQUEST['searchstring'] ?? '';
2356
2357         // error if no searchstring specified
2358         if ($searchstring == "") {
2359                 $answer = ['result' => 'error', 'message' => 'searchstring not specified'];
2360                 return DI::apiResponse()->formatData("direct_messages_search", $type, ['$result' => $answer]);
2361         }
2362
2363         // get data for the specified searchstring
2364         $r = DBA::toArray(DBA::p(
2365                 "SELECT `mail`.*, `contact`.`nurl` AS `contact-url` FROM `mail`,`contact` WHERE `mail`.`contact-id` = `contact`.`id` AND `mail`.`uid` = ? AND `body` LIKE ? ORDER BY `mail`.`id` DESC",
2366                 $uid,
2367                 '%'.$searchstring.'%'
2368         ));
2369
2370         $profile_url = $user_info["url"];
2371
2372         // message if nothing was found
2373         if (!DBA::isResult($r)) {
2374                 $success = ['success' => false, 'search_results' => 'problem with query'];
2375         } elseif (count($r) == 0) {
2376                 $success = ['success' => false, 'search_results' => 'nothing found'];
2377         } else {
2378                 $ret = [];
2379                 foreach ($r as $item) {
2380                         if ($box == "inbox" || $item['from-url'] != $profile_url) {
2381                                 $recipient = $user_info;
2382                                 $sender = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
2383                         } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
2384                                 $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
2385                                 $sender = $user_info;
2386                         }
2387
2388                         if (isset($recipient) && isset($sender)) {
2389                                 $ret[] = api_format_messages($item, $recipient, $sender);
2390                         }
2391                 }
2392                 $success = ['success' => true, 'search_results' => $ret];
2393         }
2394
2395         return DI::apiResponse()->formatData("direct_message_search", $type, ['$result' => $success]);
2396 }
2397
2398 api_register_func('api/friendica/direct_messages_search', 'api_friendica_direct_messages_search', true);