diff --git a/doc/API-Mastodon.md b/doc/API-Mastodon.md index 7df171b86f..480e81cc48 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -78,7 +78,9 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en - [`GET /api/v1/lists/:id/accounts`](https://docs.joinmastodon.org/methods/timelines/lists/) - [`POST /api/v1/lists/:id/accounts`](https://docs.joinmastodon.org/methods/timelines/lists/) - [`DELETE /api/v1/lists/:id/accounts`](https://docs.joinmastodon.org/methods/timelines/lists/) +- [`POST /api/v1/media`](https://docs.joinmastodon.org/methods/statuses/media/) - [`GET /api/v1/media/:id`](https://docs.joinmastodon.org/methods/statuses/media/) +- [`PUT /api/v1/media/:id`](https://docs.joinmastodon.org/methods/statuses/media/) - [`GET /api/v1/mutes`](https://docs.joinmastodon.org/methods/accounts/mutes/) - [`GET /api/v1/notifications`](https://docs.joinmastodon.org/methods/notifications/) - [`GET /api/v1/notifications/:id`](https://docs.joinmastodon.org/methods/notifications/) @@ -119,8 +121,6 @@ These emdpoints are planned to be implemented - [`DELETE /api/v1/conversations/:id`](https://docs.joinmastodon.org/methods/timelines/conversations/) - [`POST /api/v1/conversations/:id/read`](https://docs.joinmastodon.org/methods/timelines/conversations/) - [`GET /api/v1/instance/activity`](https://docs.joinmastodon.org/methods/instance#weekly-activity) -- [`POST /api/v1/media`](https://docs.joinmastodon.org/methods/statuses/media/) -- [`PUT /api/v1/media/:id`](https://docs.joinmastodon.org/methods/statuses/media/) - [`GET /api/v1/timelines/direct`](https://docs.joinmastodon.org/methods/timelines/) ## Non supportable endpoints diff --git a/src/Model/Photo.php b/src/Model/Photo.php index 41b41fd6f5..f79409e3f5 100644 --- a/src/Model/Photo.php +++ b/src/Model/Photo.php @@ -835,4 +835,167 @@ class Photo return DBA::exists('photo', ['resource-id' => $guid]); } + + /** + * + * @param int $uid User ID + * @param array $files uploaded file array + * @return array photo record + */ + public static function upload(int $uid, array $files) + { + Logger::info('starting new upload'); + + $user = User::getOwnerDataById($uid); + if (empty($user)) { + Logger::notice('User not found', ['uid' => $uid]); + return []; + } + + if (empty($files)) { + Logger::notice('Empty upload file'); + return []; + } + + if (!empty($files['tmp_name'])) { + if (is_array($files['tmp_name'])) { + $src = $files['tmp_name'][0]; + } else { + $src = $files['tmp_name']; + } + } else { + $src = ''; + } + + if (!empty($files['name'])) { + if (is_array($files['name'])) { + $filename = basename($files['name'][0]); + } else { + $filename = basename($files['name']); + } + } else { + $filename = ''; + } + + if (!empty($files['size'])) { + if (is_array($files['size'])) { + $filesize = intval($files['size'][0]); + } else { + $filesize = intval($files['size']); + } + } else { + $filesize = 0; + } + + if (!empty($files['type'])) { + if (is_array($files['type'])) { + $filetype = $files['type'][0]; + } else { + $filetype = $files['type']; + } + } else { + $filetype = ''; + } + + if (empty($src)) { + Logger::notice('No source file name', ['uid' => $uid, 'files' => $files]); + return []; + } + + $filetype = Images::getMimeTypeBySource($src, $filename, $filetype); + + Logger::info('File upload', ['src' => $src, 'filename' => $filename, 'size' => $filesize, 'type' => $filetype]); + + $imagedata = @file_get_contents($src); + $Image = new Image($imagedata, $filetype); + if (!$Image->isValid()) { + Logger::notice('Image is unvalid', ['uid' => $uid, 'files' => $files]); + return []; + } + + $Image->orient($src); + @unlink($src); + + $max_length = DI::config()->get('system', 'max_image_length'); + if (!$max_length) { + $max_length = MAX_IMAGE_LENGTH; + } + if ($max_length > 0) { + $Image->scaleDown($max_length); + $filesize = strlen($Image->asString()); + Logger::info('File upload: Scaling picture to new size', ['max-length' => $max_length]); + } + + $width = $Image->getWidth(); + $height = $Image->getHeight(); + + $maximagesize = DI::config()->get('system', 'maximagesize'); + + if (!empty($maximagesize) && ($filesize > $maximagesize)) { + // Scale down to multiples of 640 until the maximum size isn't exceeded anymore + foreach ([5120, 2560, 1280, 640] as $pixels) { + if (($filesize > $maximagesize) && (max($width, $height) > $pixels)) { + Logger::info('Resize', ['size' => $filesize, 'width' => $width, 'height' => $height, 'max' => $maximagesize, 'pixels' => $pixels]); + $Image->scaleDown($pixels); + $filesize = strlen($Image->asString()); + $width = $Image->getWidth(); + $height = $Image->getHeight(); + } + } + if ($filesize > $maximagesize) { + @unlink($src); + Logger::notice('Image size is too big', ['size' => $filesize, 'max' => $maximagesize]); + return []; + } + } + + $resource_id = Photo::newResource(); + $album = DI::l10n()->t('Wall Photos'); + $defperm = '<' . $user['id'] . '>'; + + $smallest = 0; + + $r = Photo::store($Image, $user['uid'], 0, $resource_id, $filename, $album, 0, 0, $defperm); + if (!$r) { + Logger::notice('Photo could not be stored'); + return []; + } + + if ($width > 640 || $height > 640) { + $Image->scaleDown(640); + $r = Photo::store($Image, $user['uid'], 0, $resource_id, $filename, $album, 1, 0, $defperm); + if ($r) { + $smallest = 1; + } + } + + if ($width > 320 || $height > 320) { + $Image->scaleDown(320); + $r = Photo::store($Image, $user['uid'], 0, $resource_id, $filename, $album, 2, 0, $defperm); + if ($r && ($smallest == 0)) { + $smallest = 2; + } + } + + $condition = ['resource-id' => $resource_id]; + $photo = self::selectFirst(['id', 'datasize', 'width', 'height', 'type'], $condition, ['order' => ['width' => true]]); + if (empty($photo)) { + Logger::notice('Photo not found', ['condition' => $condition]); + return []; + } + + $picture = []; + + $picture['id'] = $photo['id']; + $picture['size'] = $photo['datasize']; + $picture['width'] = $photo['width']; + $picture['height'] = $photo['height']; + $picture['type'] = $photo['type']; + $picture['albumpage'] = DI::baseUrl() . '/photos/' . $user['nickname'] . '/image/' . $resource_id; + $picture['picture'] = DI::baseUrl() . '/photo/{$resource_id}-0.' . $Image->getExt(); + $picture['preview'] = DI::baseUrl() . '/photo/{$resource_id}-{$smallest}.' . $Image->getExt(); + + Logger::info('upload done', ['picture' => $picture]); + return $picture; + } } diff --git a/src/Module/Api/Mastodon/Media.php b/src/Module/Api/Mastodon/Media.php index 55b8438c29..daabf53f31 100644 --- a/src/Module/Api/Mastodon/Media.php +++ b/src/Module/Api/Mastodon/Media.php @@ -21,6 +21,7 @@ namespace Friendica\Module\Api\Mastodon; +use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\DI; use Friendica\Model\Photo; @@ -31,13 +32,46 @@ use Friendica\Module\BaseApi; */ class Media extends BaseApi { + public static function post(array $parameters = []) + { + self::login(self::SCOPE_WRITE); + $uid = self::getCurrentUserID(); + + Logger::info('Photo post', ['request' => $_REQUEST, 'files' => $_FILES]); + + if (empty($_FILES['file'])) { + DI::mstdnError()->UnprocessableEntity(); + } + + $media = Photo::upload($uid, $_FILES['file']); + if (empty($media)) { + DI::mstdnError()->UnprocessableEntity(); + } + + Logger::info('Uploaded photo', ['media' => $media]); + + System::jsonExit(DI::mstdnAttachment()->createFromPhoto($media['id'])); + } + public static function put(array $parameters = []) { self::login(self::SCOPE_WRITE); $uid = self::getCurrentUserID(); $data = self::getPutData(); - self::unsupported('put'); + + if (empty($parameters['id'])) { + DI::mstdnError()->UnprocessableEntity(); + } + + $photo = Photo::selectFirst(['resource-id'], ['id' => $parameters['id'], 'uid' => $uid]); + if (empty($photo['resource-id'])) { + DI::mstdnError()->RecordNotFound(); + } + + Photo::update(['desc' => $data['description'] ?? ''], ['resource-id' => $photo['resource-id']]); + + System::jsonExit(DI::mstdnAttachment()->createFromPhoto($parameters['id'])); } /** diff --git a/static/routes.config.php b/static/routes.config.php index 6dee2164cc..580905af9c 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -112,7 +112,7 @@ return [ '/lists/{id:\d+}' => [Module\Api\Mastodon\Lists::class, [R::GET, R::PUT, R::DELETE]], '/lists/{id:\d+}/accounts' => [Module\Api\Mastodon\Lists\Accounts::class, [R::GET, R::POST, R::DELETE]], '/markers' => [Module\Api\Mastodon\Markers::class, [R::GET, R::POST]], // Dummy, not supported - '/media' => [Module\Api\Mastodon\Unimplemented::class, [ R::POST]], // @todo + '/media' => [Module\Api\Mastodon\Media::class, [ R::POST]], '/media/{id:\d+}' => [Module\Api\Mastodon\Media::class, [R::GET, R::PUT ]], '/mutes' => [Module\Api\Mastodon\Mutes::class, [R::GET ]], '/notifications' => [Module\Api\Mastodon\Notifications::class, [R::GET ]],