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