]> git.mxchange.org Git - friendica.git/blob - include/api.php
Fix Contact modules
[friendica.git] / include / api.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2021, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  * Friendica implementation of statusnet/twitter API
21  *
22  * @file include/api.php
23  * @todo Automatically detect if incoming data is HTML or BBCode
24  */
25
26 use Friendica\App;
27 use Friendica\Content\Text\BBCode;
28 use Friendica\Content\Text\HTML;
29 use Friendica\Core\Logger;
30 use Friendica\Core\Protocol;
31 use Friendica\Core\System;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Model\Contact;
35 use Friendica\Model\Group;
36 use Friendica\Model\Item;
37 use Friendica\Model\Mail;
38 use Friendica\Model\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                 DI::page()->exit(DI::apiResponse());
395         }
396 }
397
398 /**
399  *
400  * @param string  $hash
401  * @param string  $allow_cid
402  * @param string  $deny_cid
403  * @param string  $allow_gid
404  * @param string  $deny_gid
405  * @param string  $filetype
406  * @param boolean $visibility
407  * @param int     $uid
408  * @throws InternalServerErrorException
409  */
410 function post_photo_item($hash, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility, $uid)
411 {
412         // get data about the api authenticated user
413         $uri = Item::newURI(intval($uid));
414         $owner_record = DBA::selectFirst('contact', [], ['uid' => $uid, 'self' => true]);
415
416         $arr = [];
417         $arr['guid']          = System::createUUID();
418         $arr['uid']           = intval($uid);
419         $arr['uri']           = $uri;
420         $arr['type']          = 'photo';
421         $arr['wall']          = 1;
422         $arr['resource-id']   = $hash;
423         $arr['contact-id']    = $owner_record['id'];
424         $arr['owner-name']    = $owner_record['name'];
425         $arr['owner-link']    = $owner_record['url'];
426         $arr['owner-avatar']  = $owner_record['thumb'];
427         $arr['author-name']   = $owner_record['name'];
428         $arr['author-link']   = $owner_record['url'];
429         $arr['author-avatar'] = $owner_record['thumb'];
430         $arr['title']         = "";
431         $arr['allow_cid']     = $allow_cid;
432         $arr['allow_gid']     = $allow_gid;
433         $arr['deny_cid']      = $deny_cid;
434         $arr['deny_gid']      = $deny_gid;
435         $arr['visible']       = $visibility;
436         $arr['origin']        = 1;
437
438         $typetoext = [
439                         'image/jpeg' => 'jpg',
440                         'image/png' => 'png',
441                         'image/gif' => 'gif'
442                         ];
443
444         // adds link to the thumbnail scale photo
445         $arr['body'] = '[url=' . DI::baseUrl() . '/photos/' . $owner_record['nick'] . '/image/' . $hash . ']'
446                                 . '[img]' . DI::baseUrl() . '/photo/' . $hash . '-' . "2" . '.'. $typetoext[$filetype] . '[/img]'
447                                 . '[/url]';
448
449         // do the magic for storing the item in the database and trigger the federation to other contacts
450         Item::insert($arr);
451 }
452
453 /**
454  *
455  * @param string $type
456  * @param int    $scale
457  * @param string $photo_id
458  *
459  * @return array
460  * @throws BadRequestException
461  * @throws ForbiddenException
462  * @throws ImagickException
463  * @throws InternalServerErrorException
464  * @throws NotFoundException
465  * @throws UnauthorizedException
466  */
467 function prepare_photo_data($type, $scale, $photo_id, $uid)
468 {
469         $scale_sql = ($scale === false ? "" : sprintf("AND scale=%d", intval($scale)));
470         $data_sql = ($scale === false ? "" : "data, ");
471
472         // added allow_cid, allow_gid, deny_cid, deny_gid to output as string like stored in database
473         // clients needs to convert this in their way for further processing
474         $r = DBA::toArray(DBA::p(
475                 "SELECT $data_sql `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
476                                         `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`,
477                                         MIN(`scale`) AS `minscale`, MAX(`scale`) AS `maxscale`
478                         FROM `photo` WHERE `uid` = ? AND `resource-id` = ? $scale_sql GROUP BY
479                                    `resource-id`, `created`, `edited`, `title`, `desc`, `album`, `filename`,
480                                    `type`, `height`, `width`, `datasize`, `profile`, `allow_cid`, `deny_cid`, `allow_gid`, `deny_gid`",
481                 $uid,
482                 $photo_id
483         ));
484
485         $typetoext = [
486                 'image/jpeg' => 'jpg',
487                 'image/png' => 'png',
488                 'image/gif' => 'gif'
489         ];
490
491         // prepare output data for photo
492         if (DBA::isResult($r)) {
493                 $data = ['photo' => $r[0]];
494                 $data['photo']['id'] = $data['photo']['resource-id'];
495                 if ($scale !== false) {
496                         $data['photo']['data'] = base64_encode($data['photo']['data']);
497                 } else {
498                         unset($data['photo']['datasize']); //needed only with scale param
499                 }
500                 if ($type == "xml") {
501                         $data['photo']['links'] = [];
502                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
503                                 $data['photo']['links'][$k . ":link"]["@attributes"] = ["type" => $data['photo']['type'],
504                                                                                 "scale" => $k,
505                                                                                 "href" => DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']]];
506                         }
507                 } else {
508                         $data['photo']['link'] = [];
509                         // when we have profile images we could have only scales from 4 to 6, but index of array always needs to start with 0
510                         $i = 0;
511                         for ($k = intval($data['photo']['minscale']); $k <= intval($data['photo']['maxscale']); $k++) {
512                                 $data['photo']['link'][$i] = DI::baseUrl() . "/photo/" . $data['photo']['resource-id'] . "-" . $k . "." . $typetoext[$data['photo']['type']];
513                                 $i++;
514                         }
515                 }
516                 unset($data['photo']['resource-id']);
517                 unset($data['photo']['minscale']);
518                 unset($data['photo']['maxscale']);
519         } else {
520                 throw new NotFoundException();
521         }
522
523         // retrieve item element for getting activities (like, dislike etc.) related to photo
524         $condition = ['uid' => $uid, 'resource-id' => $photo_id];
525         $item = Post::selectFirst(['id', 'uid', 'uri', 'parent', 'allow_cid', 'deny_cid', 'allow_gid', 'deny_gid'], $condition);
526         if (!DBA::isResult($item)) {
527                 throw new NotFoundException('Photo-related item not found.');
528         }
529
530         $data['photo']['friendica_activities'] = DI::friendicaActivities()->createFromUriId($item['uri-id'], $item['uid'], $type);
531
532         // retrieve comments on photo
533         $condition = ["`parent` = ? AND `uid` = ? AND `gravity` IN (?, ?)",
534                 $item['parent'], $uid, GRAVITY_PARENT, GRAVITY_COMMENT];
535
536         $statuses = Post::selectForUser($uid, [], $condition);
537
538         // prepare output of comments
539         $commentData = [];
540         while ($status = DBA::fetch($statuses)) {
541                 $commentData[] = DI::twitterStatus()->createFromUriId($status['uri-id'], $status['uid'])->toArray();
542         }
543         DBA::close($statuses);
544
545         $comments = [];
546         if ($type == "xml") {
547                 $k = 0;
548                 foreach ($commentData as $comment) {
549                         $comments[$k++ . ":comment"] = $comment;
550                 }
551         } else {
552                 foreach ($commentData as $comment) {
553                         $comments[] = $comment;
554                 }
555         }
556         $data['photo']['friendica_comments'] = $comments;
557
558         // include info if rights on photo and rights on item are mismatching
559         $rights_mismatch = $data['photo']['allow_cid'] != $item['allow_cid'] ||
560                 $data['photo']['deny_cid'] != $item['deny_cid'] ||
561                 $data['photo']['allow_gid'] != $item['allow_gid'] ||
562                 $data['photo']['deny_gid'] != $item['deny_gid'];
563         $data['photo']['rights_mismatch'] = $rights_mismatch;
564
565         return $data;
566 }
567
568 /**
569  *
570  * @param string $text
571  *
572  * @return string
573  * @throws InternalServerErrorException
574  */
575 function api_clean_plain_items($text)
576 {
577         $include_entities = strtolower($_REQUEST['include_entities'] ?? 'false');
578
579         $text = BBCode::cleanPictureLinks($text);
580         $URLSearchString = "^\[\]";
581
582         $text = preg_replace("/([!#@])\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '$1$3', $text);
583
584         if ($include_entities == "true") {
585                 $text = preg_replace("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", '[url=$1]$1[/url]', $text);
586         }
587
588         // Simplify "attachment" element
589         $text = BBCode::removeAttachment($text);
590
591         return $text;
592 }
593
594 /**
595  * Add a new group to the database.
596  *
597  * @param  string $name  Group name
598  * @param  int    $uid   User ID
599  * @param  array  $users List of users to add to the group
600  *
601  * @return array
602  * @throws BadRequestException
603  */
604 function group_create($name, $uid, $users = [])
605 {
606         // error if no name specified
607         if ($name == "") {
608                 throw new BadRequestException('group name not specified');
609         }
610
611         // error message if specified group name already exists
612         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => false])) {
613                 throw new BadRequestException('group name already exists');
614         }
615
616         // Check if the group needs to be reactivated
617         if (DBA::exists('group', ['uid' => $uid, 'name' => $name, 'deleted' => true])) {
618                 $reactivate_group = true;
619         }
620
621         // create group
622         $ret = Group::create($uid, $name);
623         if ($ret) {
624                 $gid = Group::getIdByName($uid, $name);
625         } else {
626                 throw new BadRequestException('other API error');
627         }
628
629         // add members
630         $erroraddinguser = false;
631         $errorusers = [];
632         foreach ($users as $user) {
633                 $cid = $user['cid'];
634                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
635                         Group::addMember($gid, $cid);
636                 } else {
637                         $erroraddinguser = true;
638                         $errorusers[] = $cid;
639                 }
640         }
641
642         // return success message incl. missing users in array
643         $status = ($erroraddinguser ? "missing user" : ((isset($reactivate_group) && $reactivate_group) ? "reactivated" : "ok"));
644
645         return ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
646 }
647
648 /**
649  * TWITTER API
650  */
651
652 /**
653  * Deprecated function to upload media.
654  *
655  * @param string $type Return type (atom, rss, xml, json)
656  *
657  * @return array|string
658  * @throws BadRequestException
659  * @throws ForbiddenException
660  * @throws ImagickException
661  * @throws InternalServerErrorException
662  * @throws UnauthorizedException
663  */
664 function api_statuses_mediap($type)
665 {
666         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
667         $uid = BaseApi::getCurrentUserID();
668
669         $a = DI::app();
670
671         $_REQUEST['profile_uid'] = $uid;
672         $_REQUEST['api_source'] = true;
673         $txt = $_REQUEST['status'] ?? '';
674
675         if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
676                 $txt = HTML::toBBCodeVideo($txt);
677                 $config = HTMLPurifier_Config::createDefault();
678                 $config->set('Cache.DefinitionImpl', null);
679                 $purifier = new HTMLPurifier($config);
680                 $txt = $purifier->purify($txt);
681         }
682         $txt = HTML::toBBCode($txt);
683
684         $picture = wall_upload_post($a, false);
685
686         // now that we have the img url in bbcode we can add it to the status and insert the wall item.
687         $_REQUEST['body'] = $txt . "\n\n" . '[url=' . $picture["albumpage"] . '][img]' . $picture["preview"] . "[/img][/url]";
688         $item_id = item_post($a);
689
690         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
691
692         // output the post that we just posted.
693         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
694         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
695 }
696
697 /// @TODO move this to top of file or somewhere better!
698 api_register_func('api/statuses/mediap', 'api_statuses_mediap', true);
699
700 /**
701  * Updates the user’s current status.
702  *
703  * @param string $type Return type (atom, rss, xml, json)
704  *
705  * @return array|string
706  * @throws BadRequestException
707  * @throws ForbiddenException
708  * @throws ImagickException
709  * @throws InternalServerErrorException
710  * @throws TooManyRequestsException
711  * @throws UnauthorizedException
712  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
713  */
714 function api_statuses_update($type)
715 {
716         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
717         $uid = BaseApi::getCurrentUserID();
718
719         $a = DI::app();
720
721         // convert $_POST array items to the form we use for web posts.
722         if (!empty($_REQUEST['htmlstatus'])) {
723                 $txt = $_REQUEST['htmlstatus'];
724                 if ((strpos($txt, '<') !== false) || (strpos($txt, '>') !== false)) {
725                         $txt = HTML::toBBCodeVideo($txt);
726
727                         $config = HTMLPurifier_Config::createDefault();
728                         $config->set('Cache.DefinitionImpl', null);
729
730                         $purifier = new HTMLPurifier($config);
731                         $txt = $purifier->purify($txt);
732
733                         $_REQUEST['body'] = HTML::toBBCode($txt);
734                 }
735         } else {
736                 $_REQUEST['body'] = $_REQUEST['status'] ?? null;
737         }
738
739         $_REQUEST['title'] = $_REQUEST['title'] ?? null;
740
741         $parent = $_REQUEST['in_reply_to_status_id'] ?? null;
742
743         // Twidere sends "-1" if it is no reply ...
744         if ($parent == -1) {
745                 $parent = "";
746         }
747
748         if (ctype_digit($parent)) {
749                 $_REQUEST['parent'] = $parent;
750         } else {
751                 $_REQUEST['parent_uri'] = $parent;
752         }
753
754         if (!empty($_REQUEST['lat']) && !empty($_REQUEST['long'])) {
755                 $_REQUEST['coord'] = sprintf("%s %s", $_REQUEST['lat'], $_REQUEST['long']);
756         }
757         $_REQUEST['profile_uid'] = $uid;
758
759         if (!$parent) {
760                 // Check for throttling (maximum posts per day, week and month)
761                 $throttle_day = DI::config()->get('system', 'throttle_limit_day');
762                 if ($throttle_day > 0) {
763                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60);
764
765                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
766                         $posts_day = Post::count($condition);
767
768                         if ($posts_day > $throttle_day) {
769                                 logger::info('Daily posting limit reached for user ' . $uid);
770                                 // die(api_error($type, DI::l10n()->t("Daily posting limit of %d posts reached. The post was rejected.", $throttle_day));
771                                 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));
772                         }
773                 }
774
775                 $throttle_week = DI::config()->get('system', 'throttle_limit_week');
776                 if ($throttle_week > 0) {
777                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*7);
778
779                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
780                         $posts_week = Post::count($condition);
781
782                         if ($posts_week > $throttle_week) {
783                                 logger::info('Weekly posting limit reached for user ' . $uid);
784                                 // die(api_error($type, DI::l10n()->t("Weekly posting limit of %d posts reached. The post was rejected.", $throttle_week)));
785                                 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));
786                         }
787                 }
788
789                 $throttle_month = DI::config()->get('system', 'throttle_limit_month');
790                 if ($throttle_month > 0) {
791                         $datefrom = date(DateTimeFormat::MYSQL, time() - 24*60*60*30);
792
793                         $condition = ["`gravity` = ? AND `uid` = ? AND `wall` AND `received` > ?", GRAVITY_PARENT, $uid, $datefrom];
794                         $posts_month = Post::count($condition);
795
796                         if ($posts_month > $throttle_month) {
797                                 logger::info('Monthly posting limit reached for user ' . $uid);
798                                 // die(api_error($type, DI::l10n()->t("Monthly posting limit of %d posts reached. The post was rejected.", $throttle_month));
799                                 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));
800                         }
801                 }
802         }
803
804         if (!empty($_REQUEST['media_ids'])) {
805                 $ids = explode(',', $_REQUEST['media_ids']);
806         } elseif (!empty($_FILES['media'])) {
807                 // upload the image if we have one
808                 $picture = wall_upload_post($a, false);
809                 if (is_array($picture)) {
810                         $ids[] = $picture['id'];
811                 }
812         }
813
814         $attachments = [];
815         $ressources = [];
816
817         if (!empty($ids)) {
818                 foreach ($ids as $id) {
819                         $media = DBA::toArray(DBA::p("SELECT `resource-id`, `scale`, `nickname`, `type`, `desc`, `filename`, `datasize`, `width`, `height` FROM `photo`
820                                         INNER JOIN `user` ON `user`.`uid` = `photo`.`uid` WHERE `resource-id` IN
821                                                 (SELECT `resource-id` FROM `photo` WHERE `id` = ?) AND `photo`.`uid` = ?
822                                         ORDER BY `photo`.`width` DESC LIMIT 2", $id, $uid));
823
824                         if (!empty($media)) {
825                                 $ressources[] = $media[0]['resource-id'];
826                                 $phototypes = Images::supportedTypes();
827                                 $ext = $phototypes[$media[0]['type']];
828
829                                 $attachment = ['type' => Post\Media::IMAGE, 'mimetype' => $media[0]['type'],
830                                         'url' => DI::baseUrl() . '/photo/' . $media[0]['resource-id'] . '-' . $media[0]['scale'] . '.' . $ext,
831                                         'size' => $media[0]['datasize'],
832                                         'name' => $media[0]['filename'] ?: $media[0]['resource-id'],
833                                         'description' => $media[0]['desc'] ?? '',
834                                         'width' => $media[0]['width'],
835                                         'height' => $media[0]['height']];
836
837                                 if (count($media) > 1) {
838                                         $attachment['preview'] = DI::baseUrl() . '/photo/' . $media[1]['resource-id'] . '-' . $media[1]['scale'] . '.' . $ext;
839                                         $attachment['preview-width'] = $media[1]['width'];
840                                         $attachment['preview-height'] = $media[1]['height'];
841                                 }
842                                 $attachments[] = $attachment;
843                         }
844                 }
845
846                 // We have to avoid that the post is rejected because of an empty body
847                 if (empty($_REQUEST['body'])) {
848                         $_REQUEST['body'] = '[hr]';
849                 }
850         }
851
852         if (!empty($attachments)) {
853                 $_REQUEST['attachments'] = $attachments;
854         }
855
856         // set this so that the item_post() function is quiet and doesn't redirect or emit json
857
858         $_REQUEST['api_source'] = true;
859
860         if (empty($_REQUEST['source'])) {
861                 $_REQUEST['source'] = BaseApi::getCurrentApplication()['name'] ?: 'API';
862         }
863
864         // call out normal post function
865         $item_id = item_post($a);
866
867         if (!empty($ressources) && !empty($item_id)) {
868                 $item = Post::selectFirst(['uri-id', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid'], ['id' => $item_id]);
869                 foreach ($ressources as $ressource) {
870                         Photo::setPermissionForRessource($ressource, $uid, $item['allow_cid'], $item['allow_gid'], $item['deny_cid'], $item['deny_gid']);
871                 }
872         }
873
874         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
875
876         // output the post that we just posted.
877         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
878         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
879 }
880
881 api_register_func('api/statuses/update', 'api_statuses_update', true);
882 api_register_func('api/statuses/update_with_media', 'api_statuses_update', true);
883
884 /**
885  * Uploads an image to Friendica.
886  *
887  * @return array
888  * @throws BadRequestException
889  * @throws ForbiddenException
890  * @throws ImagickException
891  * @throws InternalServerErrorException
892  * @throws UnauthorizedException
893  * @see https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-upload
894  */
895 function api_media_upload()
896 {
897         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
898
899         if (empty($_FILES['media'])) {
900                 // Output error
901                 throw new BadRequestException("No media.");
902         }
903
904         $media = wall_upload_post(DI::app(), false);
905         if (!$media) {
906                 // Output error
907                 throw new InternalServerErrorException();
908         }
909
910         $returndata = [];
911         $returndata["media_id"] = $media["id"];
912         $returndata["media_id_string"] = (string)$media["id"];
913         $returndata["size"] = $media["size"];
914         $returndata["image"] = ["w" => $media["width"],
915                                 "h" => $media["height"],
916                                 "image_type" => $media["type"],
917                                 "friendica_preview_url" => $media["preview"]];
918
919         Logger::info('Media uploaded', ['return' => $returndata]);
920
921         return ["media" => $returndata];
922 }
923
924 api_register_func('api/media/upload', 'api_media_upload', true);
925
926 /**
927  * Updates media meta data (picture descriptions)
928  *
929  * @param string $type Return type (atom, rss, xml, json)
930  *
931  * @return array|string
932  * @throws BadRequestException
933  * @throws ForbiddenException
934  * @throws ImagickException
935  * @throws InternalServerErrorException
936  * @throws TooManyRequestsException
937  * @throws UnauthorizedException
938  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
939  *
940  * @todo Compare the corresponding Twitter function for correct return values
941  */
942 function api_media_metadata_create($type)
943 {
944         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
945         $uid = BaseApi::getCurrentUserID();
946
947         $postdata = Network::postdata();
948
949         if (empty($postdata)) {
950                 throw new BadRequestException("No post data");
951         }
952
953         $data = json_decode($postdata, true);
954         if (empty($data)) {
955                 throw new BadRequestException("Invalid post data");
956         }
957
958         if (empty($data['media_id']) || empty($data['alt_text'])) {
959                 throw new BadRequestException("Missing post data values");
960         }
961
962         if (empty($data['alt_text']['text'])) {
963                 throw new BadRequestException("No alt text.");
964         }
965
966         Logger::info('Updating metadata', ['media_id' => $data['media_id']]);
967
968         $condition = ['id' => $data['media_id'], 'uid' => $uid];
969         $photo = DBA::selectFirst('photo', ['resource-id'], $condition);
970         if (!DBA::isResult($photo)) {
971                 throw new BadRequestException("Metadata not found.");
972         }
973
974         DBA::update('photo', ['desc' => $data['alt_text']['text']], ['resource-id' => $photo['resource-id']]);
975 }
976
977 api_register_func('api/media/metadata/create', 'api_media_metadata_create', true);
978
979 /**
980  * Repeats a status.
981  *
982  * @param string $type Return type (atom, rss, xml, json)
983  *
984  * @return array|string
985  * @throws BadRequestException
986  * @throws ForbiddenException
987  * @throws ImagickException
988  * @throws InternalServerErrorException
989  * @throws UnauthorizedException
990  * @see https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-retweet-id
991  */
992 function api_statuses_repeat($type)
993 {
994         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
995         $uid = BaseApi::getCurrentUserID();
996
997         // params
998         $id = intval(DI::args()->getArgv()[3] ?? 0);
999
1000         if ($id == 0) {
1001                 $id = intval($_REQUEST['id'] ?? 0);
1002         }
1003
1004         // Hotot workaround
1005         if ($id == 0) {
1006                 $id = intval(DI::args()->getArgv()[4] ?? 0);
1007         }
1008
1009         logger::notice('API: api_statuses_repeat: ' . $id);
1010
1011         $fields = ['uri-id', 'network', 'body', 'title', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink'];
1012         $item = Post::selectFirst($fields, ['id' => $id, 'private' => [Item::PUBLIC, Item::UNLISTED]]);
1013
1014         if (DBA::isResult($item) && !empty($item['body'])) {
1015                 if (in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::TWITTER])) {
1016                         if (!Item::performActivity($id, 'announce', $uid)) {
1017                                 throw new InternalServerErrorException();
1018                         }
1019
1020                         $item_id = $id;
1021                 } else {
1022                         if (strpos($item['body'], "[/share]") !== false) {
1023                                 $pos = strpos($item['body'], "[share");
1024                                 $post = substr($item['body'], $pos);
1025                         } else {
1026                                 $post = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']);
1027
1028                                 if (!empty($item['title'])) {
1029                                         $post .= '[h3]' . $item['title'] . "[/h3]\n";
1030                                 }
1031
1032                                 $post .= $item['body'];
1033                                 $post .= "[/share]";
1034                         }
1035                         $_REQUEST['body'] = $post;
1036                         $_REQUEST['profile_uid'] = $uid;
1037                         $_REQUEST['api_source'] = true;
1038
1039                         if (empty($_REQUEST['source'])) {
1040                                 $_REQUEST['source'] = BaseApi::getCurrentApplication()['name'] ?: 'API';
1041                         }
1042
1043                         $item_id = item_post(DI::app());
1044                 }
1045         } else {
1046                 throw new ForbiddenException();
1047         }
1048
1049         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
1050
1051         // output the post that we just posted.
1052         $status_info = DI::twitterStatus()->createFromItemId($item_id, $include_entities)->toArray();
1053         return DI::apiResponse()->formatData('statuses', $type, ['status' => $status_info]);
1054 }
1055
1056 api_register_func('api/statuses/retweet', 'api_statuses_repeat', true);
1057
1058 /**
1059  * Star/unstar an item.
1060  * param: id : id of the item
1061  *
1062  * @param string $type Return type (atom, rss, xml, json)
1063  *
1064  * @return array|string
1065  * @throws BadRequestException
1066  * @throws ForbiddenException
1067  * @throws ImagickException
1068  * @throws InternalServerErrorException
1069  * @throws UnauthorizedException
1070  * @see https://web.archive.org/web/20131019055350/https://dev.twitter.com/docs/api/1/post/favorites/create/%3Aid
1071  */
1072 function api_favorites_create_destroy($type)
1073 {
1074         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1075         $uid = BaseApi::getCurrentUserID();
1076
1077         // for versioned api.
1078         /// @TODO We need a better global soluton
1079         $action_argv_id = 2;
1080         if (count(DI::args()->getArgv()) > 1 && DI::args()->getArgv()[1] == "1.1") {
1081                 $action_argv_id = 3;
1082         }
1083
1084         if (DI::args()->getArgc() <= $action_argv_id) {
1085                 throw new BadRequestException("Invalid request.");
1086         }
1087         $action = str_replace("." . $type, "", DI::args()->getArgv()[$action_argv_id]);
1088         if (DI::args()->getArgc() == $action_argv_id + 2) {
1089                 $itemid = intval(DI::args()->getArgv()[$action_argv_id + 1] ?? 0);
1090         } else {
1091                 $itemid = intval($_REQUEST['id'] ?? 0);
1092         }
1093
1094         $item = Post::selectFirstForUser($uid, [], ['id' => $itemid, 'uid' => $uid]);
1095
1096         if (!DBA::isResult($item)) {
1097                 throw new BadRequestException("Invalid item.");
1098         }
1099
1100         switch ($action) {
1101                 case "create":
1102                         $item['starred'] = 1;
1103                         break;
1104                 case "destroy":
1105                         $item['starred'] = 0;
1106                         break;
1107                 default:
1108                         throw new BadRequestException("Invalid action ".$action);
1109         }
1110
1111         $r = Item::update(['starred' => $item['starred']], ['id' => $itemid]);
1112
1113         if ($r === false) {
1114                 throw new InternalServerErrorException("DB error");
1115         }
1116
1117         $include_entities = strtolower(($_REQUEST['include_entities'] ?? 'false') == 'true');
1118
1119         $ret = DI::twitterStatus()->createFromUriId($item['uri-id'], $item['uid'], $include_entities)->toArray();
1120
1121         return DI::apiResponse()->formatData("status", $type, ['status' => $ret], Contact::getPublicIdByUserId($uid));
1122 }
1123
1124 api_register_func('api/favorites/create', 'api_favorites_create_destroy', true);
1125 api_register_func('api/favorites/destroy', 'api_favorites_create_destroy', true);
1126
1127 /**
1128  * Returns all lists the user subscribes to.
1129  *
1130  * @param string $type Return type (atom, rss, xml, json)
1131  *
1132  * @return array|string
1133  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-list
1134  */
1135 function api_lists_list($type)
1136 {
1137         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1138         $ret = [];
1139         /// @TODO $ret is not filled here?
1140         return DI::apiResponse()->formatData('lists', $type, ["lists_list" => $ret]);
1141 }
1142
1143 api_register_func('api/lists/list', 'api_lists_list', true);
1144 api_register_func('api/lists/subscriptions', 'api_lists_list', true);
1145
1146 /**
1147  * Returns all groups the user owns.
1148  *
1149  * @param string $type Return type (atom, rss, xml, json)
1150  *
1151  * @return array|string
1152  * @throws BadRequestException
1153  * @throws ForbiddenException
1154  * @throws ImagickException
1155  * @throws InternalServerErrorException
1156  * @throws UnauthorizedException
1157  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-ownerships
1158  */
1159 function api_lists_ownerships($type)
1160 {
1161         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1162         $uid = BaseApi::getCurrentUserID();
1163
1164         // params
1165         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1166
1167         $groups = DBA::select('group', [], ['deleted' => 0, 'uid' => $uid]);
1168
1169         // loop through all groups
1170         $lists = [];
1171         foreach ($groups as $group) {
1172                 if ($group['visible']) {
1173                         $mode = 'public';
1174                 } else {
1175                         $mode = 'private';
1176                 }
1177                 $lists[] = [
1178                         'name' => $group['name'],
1179                         'id' => intval($group['id']),
1180                         'id_str' => (string) $group['id'],
1181                         'user' => $user_info,
1182                         'mode' => $mode
1183                 ];
1184         }
1185         return DI::apiResponse()->formatData("lists", $type, ['lists' => ['lists' => $lists]]);
1186 }
1187
1188 api_register_func('api/lists/ownerships', 'api_lists_ownerships', true);
1189
1190 /**
1191  * Sends a new direct message.
1192  *
1193  * @param string $type Return type (atom, rss, xml, json)
1194  *
1195  * @return array|string
1196  * @throws BadRequestException
1197  * @throws ForbiddenException
1198  * @throws ImagickException
1199  * @throws InternalServerErrorException
1200  * @throws NotFoundException
1201  * @throws UnauthorizedException
1202  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-message
1203  */
1204 function api_direct_messages_new($type)
1205 {
1206         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1207         $uid = BaseApi::getCurrentUserID();
1208
1209         if (empty($_POST["text"]) || empty($_REQUEST['screen_name']) && empty($_REQUEST['user_id'])) {
1210                 return;
1211         }
1212
1213         $sender = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1214
1215         $cid = BaseApi::getContactIDForSearchterm($_REQUEST['screen_name'] ?? '', $_REQUEST['profileurl'] ?? '', $_REQUEST['user_id'] ?? 0, 0);
1216         if (empty($cid)) {
1217                 throw new NotFoundException('Recipient not found');
1218         }
1219
1220         $replyto = '';
1221         if (!empty($_REQUEST['replyto'])) {
1222                 $mail    = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uid' => $uid, 'id' => $_REQUEST['replyto']]);
1223                 $replyto = $mail['parent-uri'];
1224                 $sub     = $mail['title'];
1225         } else {
1226                 if (!empty($_REQUEST['title'])) {
1227                         $sub = $_REQUEST['title'];
1228                 } else {
1229                         $sub = ((strlen($_POST['text'])>10) ? substr($_POST['text'], 0, 10)."...":$_POST['text']);
1230                 }
1231         }
1232
1233         $cdata = Contact::getPublicAndUserContactID($cid, $uid);
1234
1235         $id = Mail::send($cdata['user'], $_POST['text'], $sub, $replyto);
1236
1237         if ($id > -1) {
1238                 $mail = DBA::selectFirst('mail', [], ['id' => $id]);
1239                 $ret = api_format_messages($mail, DI::twitterUser()->createFromContactId($cid, $uid, true)->toArray(), $sender);
1240         } else {
1241                 $ret = ["error" => $id];
1242         }
1243
1244         return DI::apiResponse()->formatData("direct-messages", $type, ['direct_message' => $ret], Contact::getPublicIdByUserId($uid));
1245 }
1246
1247 api_register_func('api/direct_messages/new', 'api_direct_messages_new', true);
1248
1249 /**
1250  * delete a direct_message from mail table through api
1251  *
1252  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1253  * @return string|array
1254  * @throws BadRequestException
1255  * @throws ForbiddenException
1256  * @throws ImagickException
1257  * @throws InternalServerErrorException
1258  * @throws UnauthorizedException
1259  * @see   https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/delete-message
1260  */
1261 function api_direct_messages_destroy($type)
1262 {
1263         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1264         $uid = BaseApi::getCurrentUserID();
1265
1266         //required
1267         $id = $_REQUEST['id'] ?? 0;
1268         // optional
1269         $parenturi = $_REQUEST['friendica_parenturi'] ?? '';
1270         $verbose = (!empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false");
1271         /// @todo optional parameter 'include_entities' from Twitter API not yet implemented
1272
1273         // error if no id or parenturi specified (for clients posting parent-uri as well)
1274         if ($verbose == "true" && ($id == 0 || $parenturi == "")) {
1275                 $answer = ['result' => 'error', 'message' => 'message id or parenturi not specified'];
1276                 return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1277         }
1278
1279         // BadRequestException if no id specified (for clients using Twitter API)
1280         if ($id == 0) {
1281                 throw new BadRequestException('Message id not specified');
1282         }
1283
1284         // add parent-uri to sql command if specified by calling app
1285         $sql_extra = ($parenturi != "" ? " AND `parent-uri` = '" . DBA::escape($parenturi) . "'" : "");
1286
1287         // error message if specified id is not in database
1288         if (!DBA::exists('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id])) {
1289                 if ($verbose == "true") {
1290                         $answer = ['result' => 'error', 'message' => 'message id not in database'];
1291                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1292                 }
1293                 /// @todo BadRequestException ok for Twitter API clients?
1294                 throw new BadRequestException('message id not in database');
1295         }
1296
1297         // delete message
1298         $result = DBA::delete('mail', ["`uid` = ? AND `id` = ? " . $sql_extra, $uid, $id]);
1299
1300         if ($verbose == "true") {
1301                 if ($result) {
1302                         // return success
1303                         $answer = ['result' => 'ok', 'message' => 'message deleted'];
1304                         return DI::apiResponse()->formatData("direct_message_delete", $type, ['$result' => $answer]);
1305                 } else {
1306                         $answer = ['result' => 'error', 'message' => 'unknown error'];
1307                         return DI::apiResponse()->formatData("direct_messages_delete", $type, ['$result' => $answer]);
1308                 }
1309         }
1310         /// @todo return JSON data like Twitter API not yet implemented
1311 }
1312
1313 api_register_func('api/direct_messages/destroy', 'api_direct_messages_destroy', true);
1314
1315 /**
1316  * Unfollow Contact
1317  *
1318  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1319  * @return string|array
1320  * @throws HTTPException\BadRequestException
1321  * @throws HTTPException\ExpectationFailedException
1322  * @throws HTTPException\ForbiddenException
1323  * @throws HTTPException\InternalServerErrorException
1324  * @throws HTTPException\NotFoundException
1325  * @see   https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy.html
1326  */
1327 function api_friendships_destroy($type)
1328 {
1329         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1330         $uid = BaseApi::getCurrentUserID();
1331
1332         $owner = User::getOwnerDataById($uid);
1333         if (!$owner) {
1334                 Logger::notice(BaseApi::LOG_PREFIX . 'No owner {uid} found', ['module' => 'api', 'action' => 'friendships_destroy', 'uid' => $uid]);
1335                 throw new HTTPException\NotFoundException('Error Processing Request');
1336         }
1337
1338         $contact_id = $_REQUEST['user_id'] ?? 0;
1339
1340         if (empty($contact_id)) {
1341                 Logger::notice(BaseApi::LOG_PREFIX . 'No user_id specified', ['module' => 'api', 'action' => 'friendships_destroy']);
1342                 throw new HTTPException\BadRequestException('no user_id specified');
1343         }
1344
1345         // Get Contact by given id
1346         $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => 0, 'self' => false]);
1347
1348         if(!DBA::isResult($contact)) {
1349                 Logger::notice(BaseApi::LOG_PREFIX . 'No public contact found for ID {contact}', ['module' => 'api', 'action' => 'friendships_destroy', 'contact' => $contact_id]);
1350                 throw new HTTPException\NotFoundException('no contact found to given ID');
1351         }
1352
1353         $url = $contact['url'];
1354
1355         $condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)",
1356                         $uid, Contact::SHARING, Contact::FRIEND, Strings::normaliseLink($url),
1357                         Strings::normaliseLink($url), $url];
1358         $contact = DBA::selectFirst('contact', [], $condition);
1359
1360         if (!DBA::isResult($contact)) {
1361                 Logger::notice(BaseApi::LOG_PREFIX . 'Not following contact', ['module' => 'api', 'action' => 'friendships_destroy']);
1362                 throw new HTTPException\NotFoundException('Not following Contact');
1363         }
1364
1365         try {
1366                 $result = Contact::terminateFriendship($owner, $contact);
1367
1368                 if ($result === null) {
1369                         Logger::notice(BaseApi::LOG_PREFIX . 'Not supported for {network}', ['module' => 'api', 'action' => 'friendships_destroy', 'network' => $contact['network']]);
1370                         throw new HTTPException\ExpectationFailedException('Unfollowing is currently not supported by this contact\'s network.');
1371                 }
1372
1373                 if ($result === false) {
1374                         throw new HTTPException\ServiceUnavailableException('Unable to unfollow this contact, please retry in a few minutes or contact your administrator.');
1375                 }
1376         } catch (Exception $e) {
1377                 Logger::error(BaseApi::LOG_PREFIX . $e->getMessage(), ['owner' => $owner, 'contact' => $contact]);
1378                 throw new HTTPException\InternalServerErrorException('Unable to unfollow this contact, please contact your administrator');
1379         }
1380
1381         // "uid" is only needed for some internal stuff, so remove it from here
1382         unset($contact['uid']);
1383
1384         // Set screen_name since Twidere requests it
1385         $contact['screen_name'] = $contact['nick'];
1386
1387         return DI::apiResponse()->formatData('friendships-destroy', $type, ['user' => $contact]);
1388 }
1389
1390 api_register_func('api/friendships/destroy', 'api_friendships_destroy', true);
1391
1392 /**
1393  *
1394  * @param string $type Return type (atom, rss, xml, json)
1395  * @param string $box
1396  * @param string $verbose
1397  *
1398  * @return array|string
1399  * @throws BadRequestException
1400  * @throws ForbiddenException
1401  * @throws ImagickException
1402  * @throws InternalServerErrorException
1403  * @throws UnauthorizedException
1404  */
1405 function api_direct_messages_box($type, $box, $verbose)
1406 {
1407         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1408         $uid = BaseApi::getCurrentUserID();
1409
1410         // params
1411         $count = $_GET['count'] ?? 20;
1412         $page = $_REQUEST['page'] ?? 1;
1413
1414         $since_id = $_REQUEST['since_id'] ?? 0;
1415         $max_id = $_REQUEST['max_id'] ?? 0;
1416
1417         $user_id = $_REQUEST['user_id'] ?? '';
1418         $screen_name = $_REQUEST['screen_name'] ?? '';
1419
1420         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
1421
1422         $profile_url = $user_info["url"];
1423
1424         // pagination
1425         $start = max(0, ($page - 1) * $count);
1426
1427         $sql_extra = "";
1428
1429         // filters
1430         if ($box=="sentbox") {
1431                 $sql_extra = "`mail`.`from-url`='" . DBA::escape($profile_url) . "'";
1432         } elseif ($box == "conversation") {
1433                 $sql_extra = "`mail`.`parent-uri`='" . DBA::escape($_GET['uri'] ?? '')  . "'";
1434         } elseif ($box == "all") {
1435                 $sql_extra = "true";
1436         } elseif ($box == "inbox") {
1437                 $sql_extra = "`mail`.`from-url`!='" . DBA::escape($profile_url) . "'";
1438         }
1439
1440         if ($max_id > 0) {
1441                 $sql_extra .= ' AND `mail`.`id` <= ' . intval($max_id);
1442         }
1443
1444         if ($user_id != "") {
1445                 $sql_extra .= ' AND `mail`.`contact-id` = ' . intval($user_id);
1446         } elseif ($screen_name !="") {
1447                 $sql_extra .= " AND `contact`.`nick` = '" . DBA::escape($screen_name). "'";
1448         }
1449
1450         $r = DBA::toArray(DBA::p(
1451                 "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 ?,?",
1452                 $uid,
1453                 $since_id,
1454                 $start,
1455                 $count
1456         ));
1457         if ($verbose == "true" && !DBA::isResult($r)) {
1458                 $answer = ['result' => 'error', 'message' => 'no mails available'];
1459                 return DI::apiResponse()->formatData("direct_messages_all", $type, ['$result' => $answer]);
1460         }
1461
1462         $ret = [];
1463         foreach ($r as $item) {
1464                 if ($box == "inbox" || $item['from-url'] != $profile_url) {
1465                         $recipient = $user_info;
1466                         $sender = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
1467                 } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
1468                         $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
1469                         $sender = $user_info;
1470                 }
1471
1472                 if (isset($recipient) && isset($sender)) {
1473                         $ret[] = api_format_messages($item, $recipient, $sender);
1474                 }
1475         }
1476
1477         return DI::apiResponse()->formatData("direct-messages", $type, ['direct_message' => $ret], Contact::getPublicIdByUserId($uid));
1478 }
1479
1480 /**
1481  * Returns the most recent direct messages sent by the user.
1482  *
1483  * @param string $type Return type (atom, rss, xml, json)
1484  *
1485  * @return array|string
1486  * @throws BadRequestException
1487  * @throws ForbiddenException
1488  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-sent-message
1489  */
1490 function api_direct_messages_sentbox($type)
1491 {
1492         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1493         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1494         return api_direct_messages_box($type, "sentbox", $verbose);
1495 }
1496
1497 api_register_func('api/direct_messages/sent', 'api_direct_messages_sentbox', true);
1498
1499 /**
1500  * Returns the most recent direct messages sent to the user.
1501  *
1502  * @param string $type Return type (atom, rss, xml, json)
1503  *
1504  * @return array|string
1505  * @throws BadRequestException
1506  * @throws ForbiddenException
1507  * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-messages
1508  */
1509 function api_direct_messages_inbox($type)
1510 {
1511         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1512         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1513         return api_direct_messages_box($type, "inbox", $verbose);
1514 }
1515
1516 api_register_func('api/direct_messages', 'api_direct_messages_inbox', true);
1517
1518 /**
1519  *
1520  * @param string $type Return type (atom, rss, xml, json)
1521  *
1522  * @return array|string
1523  * @throws BadRequestException
1524  * @throws ForbiddenException
1525  */
1526 function api_direct_messages_all($type)
1527 {
1528         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1529         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1530         return api_direct_messages_box($type, "all", $verbose);
1531 }
1532
1533 api_register_func('api/direct_messages/all', 'api_direct_messages_all', true);
1534
1535 /**
1536  *
1537  * @param string $type Return type (atom, rss, xml, json)
1538  *
1539  * @return array|string
1540  * @throws BadRequestException
1541  * @throws ForbiddenException
1542  */
1543 function api_direct_messages_conversation($type)
1544 {
1545         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1546         $verbose = !empty($_GET['friendica_verbose']) ? strtolower($_GET['friendica_verbose']) : "false";
1547         return api_direct_messages_box($type, "conversation", $verbose);
1548 }
1549
1550 api_register_func('api/direct_messages/conversation', 'api_direct_messages_conversation', true);
1551
1552 /**
1553  * list all photos of the authenticated user
1554  *
1555  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1556  * @return string|array
1557  * @throws ForbiddenException
1558  * @throws InternalServerErrorException
1559  */
1560 function api_fr_photos_list($type)
1561 {
1562         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1563         $uid = BaseApi::getCurrentUserID();
1564
1565         $r = DBA::toArray(DBA::p(
1566                 "SELECT `resource-id`, MAX(scale) AS `scale`, `album`, `filename`, `type`, MAX(`created`) AS `created`,
1567                 MAX(`edited`) AS `edited`, MAX(`desc`) AS `desc` FROM `photo`
1568                 WHERE `uid` = ? AND NOT `photo-type` IN (?, ?) GROUP BY `resource-id`, `album`, `filename`, `type`",
1569                 $uid, Photo::CONTACT_AVATAR, Photo::CONTACT_BANNER
1570         ));
1571         $typetoext = [
1572                 'image/jpeg' => 'jpg',
1573                 'image/png' => 'png',
1574                 'image/gif' => 'gif'
1575         ];
1576         $data = ['photo'=>[]];
1577         if (DBA::isResult($r)) {
1578                 foreach ($r as $rr) {
1579                         $photo = [];
1580                         $photo['id'] = $rr['resource-id'];
1581                         $photo['album'] = $rr['album'];
1582                         $photo['filename'] = $rr['filename'];
1583                         $photo['type'] = $rr['type'];
1584                         $thumb = DI::baseUrl() . "/photo/" . $rr['resource-id'] . "-" . $rr['scale'] . "." . $typetoext[$rr['type']];
1585                         $photo['created'] = $rr['created'];
1586                         $photo['edited'] = $rr['edited'];
1587                         $photo['desc'] = $rr['desc'];
1588
1589                         if ($type == "xml") {
1590                                 $data['photo'][] = ["@attributes" => $photo, "1" => $thumb];
1591                         } else {
1592                                 $photo['thumb'] = $thumb;
1593                                 $data['photo'][] = $photo;
1594                         }
1595                 }
1596         }
1597         return DI::apiResponse()->formatData("photos", $type, $data);
1598 }
1599
1600 api_register_func('api/friendica/photos/list', 'api_fr_photos_list', true);
1601
1602 /**
1603  * upload a new photo or change an existing photo
1604  *
1605  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1606  * @return string|array
1607  * @throws BadRequestException
1608  * @throws ForbiddenException
1609  * @throws ImagickException
1610  * @throws InternalServerErrorException
1611  * @throws NotFoundException
1612  */
1613 function api_fr_photo_create_update($type)
1614 {
1615         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1616         $uid = BaseApi::getCurrentUserID();
1617
1618         // input params
1619         $photo_id  = $_REQUEST['photo_id']  ?? null;
1620         $desc      = $_REQUEST['desc']      ?? null;
1621         $album     = $_REQUEST['album']     ?? null;
1622         $album_new = $_REQUEST['album_new'] ?? null;
1623         $allow_cid = $_REQUEST['allow_cid'] ?? null;
1624         $deny_cid  = $_REQUEST['deny_cid' ] ?? null;
1625         $allow_gid = $_REQUEST['allow_gid'] ?? null;
1626         $deny_gid  = $_REQUEST['deny_gid' ] ?? null;
1627         $visibility = !$allow_cid && !$deny_cid && !$allow_gid && !$deny_gid;
1628
1629         // do several checks on input parameters
1630         // we do not allow calls without album string
1631         if ($album == null) {
1632                 throw new BadRequestException("no albumname specified");
1633         }
1634         // if photo_id == null --> we are uploading a new photo
1635         if ($photo_id == null) {
1636                 $mode = "create";
1637
1638                 // error if no media posted in create-mode
1639                 if (empty($_FILES['media'])) {
1640                         // Output error
1641                         throw new BadRequestException("no media data submitted");
1642                 }
1643
1644                 // album_new will be ignored in create-mode
1645                 $album_new = "";
1646         } else {
1647                 $mode = "update";
1648
1649                 // check if photo is existing in databasei
1650                 if (!Photo::exists(['resource-id' => $photo_id, 'uid' => $uid, 'album' => $album])) {
1651                         throw new BadRequestException("photo not available");
1652                 }
1653         }
1654
1655         // checks on acl strings provided by clients
1656         $acl_input_error = false;
1657         $acl_input_error |= check_acl_input($allow_cid, $uid);
1658         $acl_input_error |= check_acl_input($deny_cid, $uid);
1659         $acl_input_error |= check_acl_input($allow_gid, $uid);
1660         $acl_input_error |= check_acl_input($deny_gid, $uid);
1661         if ($acl_input_error) {
1662                 throw new BadRequestException("acl data invalid");
1663         }
1664         // now let's upload the new media in create-mode
1665         if ($mode == "create") {
1666                 $media = $_FILES['media'];
1667                 $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);
1668
1669                 // return success of updating or error message
1670                 if (!is_null($data)) {
1671                         return DI::apiResponse()->formatData("photo_create", $type, $data);
1672                 } else {
1673                         throw new InternalServerErrorException("unknown error - uploading photo failed, see Friendica log for more information");
1674                 }
1675         }
1676
1677         // now let's do the changes in update-mode
1678         if ($mode == "update") {
1679                 $updated_fields = [];
1680
1681                 if (!is_null($desc)) {
1682                         $updated_fields['desc'] = $desc;
1683                 }
1684
1685                 if (!is_null($album_new)) {
1686                         $updated_fields['album'] = $album_new;
1687                 }
1688
1689                 if (!is_null($allow_cid)) {
1690                         $allow_cid = trim($allow_cid);
1691                         $updated_fields['allow_cid'] = $allow_cid;
1692                 }
1693
1694                 if (!is_null($deny_cid)) {
1695                         $deny_cid = trim($deny_cid);
1696                         $updated_fields['deny_cid'] = $deny_cid;
1697                 }
1698
1699                 if (!is_null($allow_gid)) {
1700                         $allow_gid = trim($allow_gid);
1701                         $updated_fields['allow_gid'] = $allow_gid;
1702                 }
1703
1704                 if (!is_null($deny_gid)) {
1705                         $deny_gid = trim($deny_gid);
1706                         $updated_fields['deny_gid'] = $deny_gid;
1707                 }
1708
1709                 $result = false;
1710                 if (count($updated_fields) > 0) {
1711                         $nothingtodo = false;
1712                         $result = Photo::update($updated_fields, ['uid' => $uid, 'resource-id' => $photo_id, 'album' => $album]);
1713                 } else {
1714                         $nothingtodo = true;
1715                 }
1716
1717                 if (!empty($_FILES['media'])) {
1718                         $nothingtodo = false;
1719                         $media = $_FILES['media'];
1720                         $data = save_media_to_database("photo", $media, $type, $album, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $desc, Photo::DEFAULT, $visibility, $photo_id, $uid);
1721                         if (!is_null($data)) {
1722                                 return DI::apiResponse()->formatData("photo_update", $type, $data);
1723                         }
1724                 }
1725
1726                 // return success of updating or error message
1727                 if ($result) {
1728                         $answer = ['result' => 'updated', 'message' => 'Image id `' . $photo_id . '` has been updated.'];
1729                         return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
1730                 } else {
1731                         if ($nothingtodo) {
1732                                 $answer = ['result' => 'cancelled', 'message' => 'Nothing to update for image id `' . $photo_id . '`.'];
1733                                 return DI::apiResponse()->formatData("photo_update", $type, ['$result' => $answer]);
1734                         }
1735                         throw new InternalServerErrorException("unknown error - update photo entry in database failed");
1736                 }
1737         }
1738         throw new InternalServerErrorException("unknown error - this error on uploading or updating a photo should never happen");
1739 }
1740
1741 api_register_func('api/friendica/photo/create', 'api_fr_photo_create_update', true);
1742 api_register_func('api/friendica/photo/update', 'api_fr_photo_create_update', true);
1743
1744 /**
1745  * returns the details of a specified photo id, if scale is given, returns the photo data in base 64
1746  *
1747  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1748  * @return string|array
1749  * @throws BadRequestException
1750  * @throws ForbiddenException
1751  * @throws InternalServerErrorException
1752  * @throws NotFoundException
1753  */
1754 function api_fr_photo_detail($type)
1755 {
1756         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1757         $uid = BaseApi::getCurrentUserID();
1758
1759         if (empty($_REQUEST['photo_id'])) {
1760                 throw new BadRequestException("No photo id.");
1761         }
1762
1763         $scale = (!empty($_REQUEST['scale']) ? intval($_REQUEST['scale']) : false);
1764         $photo_id = $_REQUEST['photo_id'];
1765
1766         // prepare json/xml output with data from database for the requested photo
1767         $data = prepare_photo_data($type, $scale, $photo_id, $uid);
1768
1769         return DI::apiResponse()->formatData("photo_detail", $type, $data);
1770 }
1771
1772 api_register_func('api/friendica/photo', 'api_fr_photo_detail', true);
1773
1774 /**
1775  * updates the profile image for the user (either a specified profile or the default profile)
1776  *
1777  * @param string $type Known types are 'atom', 'rss', 'xml' and 'json'
1778  *
1779  * @return string|array
1780  * @throws BadRequestException
1781  * @throws ForbiddenException
1782  * @throws ImagickException
1783  * @throws InternalServerErrorException
1784  * @throws NotFoundException
1785  * @see   https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image
1786  */
1787 function api_account_update_profile_image($type)
1788 {
1789         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1790         $uid = BaseApi::getCurrentUserID();
1791
1792         // input params
1793         $profile_id = $_REQUEST['profile_id'] ?? 0;
1794
1795         // error if image data is missing
1796         if (empty($_FILES['image'])) {
1797                 throw new BadRequestException("no media data submitted");
1798         }
1799
1800         // check if specified profile id is valid
1801         if ($profile_id != 0) {
1802                 $profile = DBA::selectFirst('profile', ['is-default'], ['uid' => $uid, 'id' => $profile_id]);
1803                 // error message if specified profile id is not in database
1804                 if (!DBA::isResult($profile)) {
1805                         throw new BadRequestException("profile_id not available");
1806                 }
1807                 $is_default_profile = $profile['is-default'];
1808         } else {
1809                 $is_default_profile = 1;
1810         }
1811
1812         // get mediadata from image or media (Twitter call api/account/update_profile_image provides image)
1813         $media = null;
1814         if (!empty($_FILES['image'])) {
1815                 $media = $_FILES['image'];
1816         } elseif (!empty($_FILES['media'])) {
1817                 $media = $_FILES['media'];
1818         }
1819         // save new profile image
1820         $data = save_media_to_database("profileimage", $media, $type, DI::l10n()->t(Photo::PROFILE_PHOTOS), "", "", "", "", "", Photo::USER_AVATAR, false, null, $uid);
1821
1822         // get filetype
1823         if (is_array($media['type'])) {
1824                 $filetype = $media['type'][0];
1825         } else {
1826                 $filetype = $media['type'];
1827         }
1828         if ($filetype == "image/jpeg") {
1829                 $fileext = "jpg";
1830         } elseif ($filetype == "image/png") {
1831                 $fileext = "png";
1832         } else {
1833                 throw new InternalServerErrorException('Unsupported filetype');
1834         }
1835
1836         // change specified profile or all profiles to the new resource-id
1837         if ($is_default_profile) {
1838                 $condition = ["`profile` AND `resource-id` != ? AND `uid` = ?", $data['photo']['id'], $uid];
1839                 Photo::update(['profile' => false, 'photo-type' => Photo::DEFAULT], $condition);
1840         } else {
1841                 $fields = ['photo' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-4.' . $fileext,
1842                         'thumb' => DI::baseUrl() . '/photo/' . $data['photo']['id'] . '-5.' . $fileext];
1843                 DBA::update('profile', $fields, ['id' => $_REQUEST['profile'], 'uid' => $uid]);
1844         }
1845
1846         Contact::updateSelfFromUserID($uid, true);
1847
1848         // Update global directory in background
1849         Profile::publishUpdate($uid);
1850
1851         // output for client
1852         if ($data) {
1853                 $skip_status = $_REQUEST['skip_status'] ?? false;
1854
1855                 $user_info = DI::twitterUser()->createFromUserId($uid, $skip_status)->toArray();
1856
1857                 // "verified" isn't used here in the standard
1858                 unset($user_info["verified"]);
1859
1860                 // "uid" is only needed for some internal stuff, so remove it from here
1861                 unset($user_info['uid']);
1862
1863                 return DI::apiResponse()->formatData("user", $type, ['user' => $user_info]);
1864         } else {
1865                 // SaveMediaToDatabase failed for some reason
1866                 throw new InternalServerErrorException("image upload failed");
1867         }
1868 }
1869
1870 api_register_func('api/account/update_profile_image', 'api_account_update_profile_image', true);
1871
1872 /**
1873  * Return all or a specified group of the user with the containing contacts.
1874  *
1875  * @param string $type Return type (atom, rss, xml, json)
1876  *
1877  * @return array|string
1878  * @throws BadRequestException
1879  * @throws ForbiddenException
1880  * @throws ImagickException
1881  * @throws InternalServerErrorException
1882  * @throws UnauthorizedException
1883  */
1884 function api_friendica_group_show($type)
1885 {
1886         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
1887         $uid = BaseApi::getCurrentUserID();
1888
1889         // params
1890         $gid = $_REQUEST['gid'] ?? 0;
1891
1892         // get data of the specified group id or all groups if not specified
1893         if ($gid != 0) {
1894                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid, 'id' => $gid]);
1895
1896                 // error message if specified gid is not in database
1897                 if (!DBA::isResult($groups)) {
1898                         throw new BadRequestException("gid not available");
1899                 }
1900         } else {
1901                 $groups = DBA::selectToArray('group', [], ['deleted' => false, 'uid' => $uid]);
1902         }
1903
1904         // loop through all groups and retrieve all members for adding data in the user array
1905         $grps = [];
1906         foreach ($groups as $rr) {
1907                 $members = Contact\Group::getById($rr['id']);
1908                 $users = [];
1909
1910                 if ($type == "xml") {
1911                         $user_element = "users";
1912                         $k = 0;
1913                         foreach ($members as $member) {
1914                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], $uid, true)->toArray();
1915                                 $users[$k++.":user"] = $user;
1916                         }
1917                 } else {
1918                         $user_element = "user";
1919                         foreach ($members as $member) {
1920                                 $user = DI::twitterUser()->createFromContactId($member['contact-id'], $uid, true)->toArray();
1921                                 $users[] = $user;
1922                         }
1923                 }
1924                 $grps[] = ['name' => $rr['name'], 'gid' => $rr['id'], $user_element => $users];
1925         }
1926         return DI::apiResponse()->formatData("groups", $type, ['group' => $grps]);
1927 }
1928
1929 api_register_func('api/friendica/group_show', 'api_friendica_group_show', true);
1930
1931 /**
1932  * Delete a group.
1933  *
1934  * @param string $type Return type (atom, rss, xml, json)
1935  *
1936  * @return array|string
1937  * @throws BadRequestException
1938  * @throws ForbiddenException
1939  * @throws ImagickException
1940  * @throws InternalServerErrorException
1941  * @throws UnauthorizedException
1942  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-destroy
1943  */
1944 function api_lists_destroy($type)
1945 {
1946         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1947         $uid = BaseApi::getCurrentUserID();
1948
1949         // params
1950         $gid = $_REQUEST['list_id'] ?? 0;
1951
1952         // error if no gid specified
1953         if ($gid == 0) {
1954                 throw new BadRequestException('gid not specified');
1955         }
1956
1957         // get data of the specified group id
1958         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
1959         // error message if specified gid is not in database
1960         if (!$group) {
1961                 throw new BadRequestException('gid not available');
1962         }
1963
1964         if (Group::remove($gid)) {
1965                 $list = [
1966                         'name' => $group['name'],
1967                         'id' => intval($gid),
1968                         'id_str' => (string) $gid,
1969                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
1970                 ];
1971
1972                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
1973         }
1974 }
1975
1976 api_register_func('api/lists/destroy', 'api_lists_destroy', true);
1977
1978 /**
1979  * Create the specified group with the posted array of contacts.
1980  *
1981  * @param string $type Return type (atom, rss, xml, json)
1982  *
1983  * @return array|string
1984  * @throws BadRequestException
1985  * @throws ForbiddenException
1986  * @throws ImagickException
1987  * @throws InternalServerErrorException
1988  * @throws UnauthorizedException
1989  */
1990 function api_friendica_group_create($type)
1991 {
1992         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
1993         $uid = BaseApi::getCurrentUserID();
1994
1995         // params
1996         $name = $_REQUEST['name'] ?? '';
1997         $json = json_decode($_POST['json'], true);
1998         $users = $json['user'];
1999
2000         $success = group_create($name, $uid, $users);
2001
2002         return DI::apiResponse()->formatData("group_create", $type, ['result' => $success]);
2003 }
2004
2005 api_register_func('api/friendica/group_create', 'api_friendica_group_create', true);
2006
2007 /**
2008  * Create a new group.
2009  *
2010  * @param string $type Return type (atom, rss, xml, json)
2011  *
2012  * @return array|string
2013  * @throws BadRequestException
2014  * @throws ForbiddenException
2015  * @throws ImagickException
2016  * @throws InternalServerErrorException
2017  * @throws UnauthorizedException
2018  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-create
2019  */
2020 function api_lists_create($type)
2021 {
2022         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2023         $uid = BaseApi::getCurrentUserID();
2024
2025         // params
2026         $name = $_REQUEST['name'] ?? '';
2027
2028         $success = group_create($name, $uid);
2029         if ($success['success']) {
2030                 $grp = [
2031                         'name' => $success['name'],
2032                         'id' => intval($success['gid']),
2033                         'id_str' => (string) $success['gid'],
2034                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
2035                 ];
2036
2037                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $grp]);
2038         }
2039 }
2040
2041 api_register_func('api/lists/create', 'api_lists_create', true);
2042
2043 /**
2044  * Update the specified group with the posted array of contacts.
2045  *
2046  * @param string $type Return type (atom, rss, xml, json)
2047  *
2048  * @return array|string
2049  * @throws BadRequestException
2050  * @throws ForbiddenException
2051  * @throws ImagickException
2052  * @throws InternalServerErrorException
2053  * @throws UnauthorizedException
2054  */
2055 function api_friendica_group_update($type)
2056 {
2057         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2058         $uid = BaseApi::getCurrentUserID();
2059
2060         // params
2061         $gid = $_REQUEST['gid'] ?? 0;
2062         $name = $_REQUEST['name'] ?? '';
2063         $json = json_decode($_POST['json'], true);
2064         $users = $json['user'];
2065
2066         // error if no name specified
2067         if ($name == "") {
2068                 throw new BadRequestException('group name not specified');
2069         }
2070
2071         // error if no gid specified
2072         if ($gid == "") {
2073                 throw new BadRequestException('gid not specified');
2074         }
2075
2076         // remove members
2077         $members = Contact\Group::getById($gid);
2078         foreach ($members as $member) {
2079                 $cid = $member['id'];
2080                 foreach ($users as $user) {
2081                         $found = ($user['cid'] == $cid ? true : false);
2082                 }
2083                 if (!isset($found) || !$found) {
2084                         $gid = Group::getIdByName($uid, $name);
2085                         Group::removeMember($gid, $cid);
2086                 }
2087         }
2088
2089         // add members
2090         $erroraddinguser = false;
2091         $errorusers = [];
2092         foreach ($users as $user) {
2093                 $cid = $user['cid'];
2094
2095                 if (DBA::exists('contact', ['id' => $cid, 'uid' => $uid])) {
2096                         Group::addMember($gid, $cid);
2097                 } else {
2098                         $erroraddinguser = true;
2099                         $errorusers[] = $cid;
2100                 }
2101         }
2102
2103         // return success message incl. missing users in array
2104         $status = ($erroraddinguser ? "missing user" : "ok");
2105         $success = ['success' => true, 'gid' => $gid, 'name' => $name, 'status' => $status, 'wrong users' => $errorusers];
2106         return DI::apiResponse()->formatData("group_update", $type, ['result' => $success]);
2107 }
2108
2109 api_register_func('api/friendica/group_update', 'api_friendica_group_update', true);
2110
2111 /**
2112  * Update information about a group.
2113  *
2114  * @param string $type Return type (atom, rss, xml, json)
2115  *
2116  * @return array|string
2117  * @throws BadRequestException
2118  * @throws ForbiddenException
2119  * @throws ImagickException
2120  * @throws InternalServerErrorException
2121  * @throws UnauthorizedException
2122  * @see https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-update
2123  */
2124 function api_lists_update($type)
2125 {
2126         BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
2127         $uid = BaseApi::getCurrentUserID();
2128
2129         // params
2130         $gid = $_REQUEST['list_id'] ?? 0;
2131         $name = $_REQUEST['name'] ?? '';
2132
2133         // error if no gid specified
2134         if ($gid == 0) {
2135                 throw new BadRequestException('gid not specified');
2136         }
2137
2138         // get data of the specified group id
2139         $group = DBA::selectFirst('group', [], ['uid' => $uid, 'id' => $gid]);
2140         // error message if specified gid is not in database
2141         if (!$group) {
2142                 throw new BadRequestException('gid not available');
2143         }
2144
2145         if (Group::update($gid, $name)) {
2146                 $list = [
2147                         'name' => $name,
2148                         'id' => intval($gid),
2149                         'id_str' => (string) $gid,
2150                         'user' => DI::twitterUser()->createFromUserId($uid, true)->toArray()
2151                 ];
2152
2153                 return DI::apiResponse()->formatData("lists", $type, ['lists' => $list]);
2154         }
2155 }
2156
2157 api_register_func('api/lists/update', 'api_lists_update', true);
2158
2159 /**
2160  * search for direct_messages containing a searchstring through api
2161  *
2162  * @param string $type      Known types are 'atom', 'rss', 'xml' and 'json'
2163  * @param string $box
2164  * @return string|array (success: success=true if found and search_result contains found messages,
2165  *                          success=false if nothing was found, search_result='nothing found',
2166  *                          error: result=error with error message)
2167  * @throws BadRequestException
2168  * @throws ForbiddenException
2169  * @throws ImagickException
2170  * @throws InternalServerErrorException
2171  * @throws UnauthorizedException
2172  */
2173 function api_friendica_direct_messages_search($type, $box = "")
2174 {
2175         BaseApi::checkAllowedScope(BaseApi::SCOPE_READ);
2176         $uid = BaseApi::getCurrentUserID();
2177
2178         // params
2179         $user_info = DI::twitterUser()->createFromUserId($uid, true)->toArray();
2180         $searchstring = $_REQUEST['searchstring'] ?? '';
2181
2182         // error if no searchstring specified
2183         if ($searchstring == "") {
2184                 $answer = ['result' => 'error', 'message' => 'searchstring not specified'];
2185                 return DI::apiResponse()->formatData("direct_messages_search", $type, ['$result' => $answer]);
2186         }
2187
2188         // get data for the specified searchstring
2189         $r = DBA::toArray(DBA::p(
2190                 "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",
2191                 $uid,
2192                 '%'.$searchstring.'%'
2193         ));
2194
2195         $profile_url = $user_info["url"];
2196
2197         // message if nothing was found
2198         if (!DBA::isResult($r)) {
2199                 $success = ['success' => false, 'search_results' => 'problem with query'];
2200         } elseif (count($r) == 0) {
2201                 $success = ['success' => false, 'search_results' => 'nothing found'];
2202         } else {
2203                 $ret = [];
2204                 foreach ($r as $item) {
2205                         if ($box == "inbox" || $item['from-url'] != $profile_url) {
2206                                 $recipient = $user_info;
2207                                 $sender = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
2208                         } elseif ($box == "sentbox" || $item['from-url'] == $profile_url) {
2209                                 $recipient = DI::twitterUser()->createFromContactId($item['contact-id'], $uid, true)->toArray();
2210                                 $sender = $user_info;
2211                         }
2212
2213                         if (isset($recipient) && isset($sender)) {
2214                                 $ret[] = api_format_messages($item, $recipient, $sender);
2215                         }
2216                 }
2217                 $success = ['success' => true, 'search_results' => $ret];
2218         }
2219
2220         return DI::apiResponse()->formatData("direct_message_search", $type, ['$result' => $success]);
2221 }
2222
2223 api_register_func('api/friendica/direct_messages_search', 'api_friendica_direct_messages_search', true);