From 167dbcdd43f7b14b99874bc69af5e270935231c3 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Thu, 11 May 2023 04:06:27 -0600 Subject: [PATCH] Add /api/v2/instance api endpoint --- app/Http/Controllers/Api/ApiV1Controller.php | 41 --- app/Http/Controllers/Api/ApiV2Controller.php | 332 +++++++++++++++++++ app/Services/InstanceService.php | 40 +++ config/app.php | 1 + routes/api.php | 7 +- 5 files changed, 377 insertions(+), 44 deletions(-) create mode 100644 app/Http/Controllers/Api/ApiV2Controller.php diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index af46f8af3..a51e16de0 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -119,16 +119,6 @@ class ApiV1Controller extends Controller return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES); } - public function getWebsocketConfig() - { - return config('broadcasting.default') === 'pusher' ? [ - 'host' => config('broadcasting.connections.pusher.options.host'), - 'port' => config('broadcasting.connections.pusher.options.port'), - 'key' => config('broadcasting.connections.pusher.key'), - 'cluster' => config('broadcasting.connections.pusher.options.cluster') - ] : []; - } - public function getApp(Request $request) { if(!$request->user()) { @@ -3469,37 +3459,6 @@ class ApiV1Controller extends Controller return $this->json($res); } - /** - * GET /api/v2/search - * - * - * @return array - */ - public function searchV2(Request $request) - { - abort_if(!$request->user(), 403); - - if(config('pixelfed.bouncer.cloud_ips.ban_api')) { - abort_if(BouncerService::checkIp($request->ip()), 404); - } - - $this->validate($request, [ - 'q' => 'required|string|min:1|max:100', - 'account_id' => 'nullable|string', - 'max_id' => 'nullable|string', - 'min_id' => 'nullable|string', - 'type' => 'nullable|in:accounts,hashtags,statuses', - 'exclude_unreviewed' => 'nullable', - 'resolve' => 'nullable', - 'limit' => 'nullable|integer|max:40', - 'offset' => 'nullable|integer', - 'following' => 'nullable' - ]); - - $mastodonMode = !$request->has('_pe'); - return $this->json(SearchApiV2Service::query($request, $mastodonMode)); - } - /** * GET /api/v1/discover/posts * diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php new file mode 100644 index 000000000..f470a7ced --- /dev/null +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -0,0 +1,332 @@ +json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + + public function instance(Request $request) + { + $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () { + if(config_cache('instance.admin.pid')) { + return AccountService::getMastodon(config_cache('instance.admin.pid'), true); + } + $admin = User::whereIsAdmin(true)->first(); + return $admin && isset($admin->profile_id) ? + AccountService::getMastodon($admin->profile_id, true) : + null; + }); + + $rules = Cache::remember('api:v1:instance-data:rules', 604800, function () { + return config_cache('app.rules') ? + collect(json_decode(config_cache('app.rules'), true)) + ->map(function($rule, $key) { + $id = $key + 1; + return [ + 'id' => "{$id}", + 'text' => $rule + ]; + }) + ->toArray() : []; + }); + + $res = [ + 'domain' => config('pixelfed.domain.app'), + 'title' => config_cache('app.name'), + 'version' => config('pixelfed.version'), + 'source_url' => 'https://github.com/pixelfed/pixelfed', + 'description' => config_cache('app.short_description'), + 'usage' => [ + 'users' => [ + 'active_month' => (int) Cache::remember('api:nodeinfo:am', 172800, function() { + return User::select('last_active_at', 'created_at') + ->where('last_active_at', '>', now()->subMonths(1)) + ->orWhere('created_at', '>', now()->subMonths(1)) + ->count(); + }) + ] + ], + 'thumbnail' => [ + 'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), + 'blurhash' => InstanceService::headerBlurhash(), + 'versions' => [ + '@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), + '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')) + ] + ], + 'languages' => [config('app.locale')], + 'configuration' => [ + 'urls' => [ + 'streaming' => 'wss://' . config('pixelfed.domain.app'), + 'status' => null + ], + 'accounts' => [ + 'max_featured_tags' => 0, + ], + 'statuses' => [ + 'max_characters' => (int) config('pixelfed.max_caption_length'), + 'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'), + 'characters_reserved_per_url' => 23 + ], + 'media_attachments' => [ + 'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')), + 'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, + 'image_matrix_limit' => 3686400, + 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, + 'video_frame_rate_limit' => 240, + 'video_matrix_limit' => 3686400 + ], + 'polls' => [ + 'max_options' => 4, + 'max_characters_per_option' => 50, + 'min_expiration' => 300, + 'max_expiration' => 2629746, + ], + 'translation' => [ + 'enabled' => false, + ], + ], + 'registrations' => [ + 'enabled' => (bool) config_cache('pixelfed.open_registration'), + 'approval_required' => false, + 'message' => null + ], + 'contact' => [ + 'email' => config('instance.email'), + 'account' => $contact + ], + 'rules' => $rules + ]; + + return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); + } + + /** + * GET /api/v2/search + * + * + * @return array + */ + public function search(Request $request) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + + $this->validate($request, [ + 'q' => 'required|string|min:1|max:100', + 'account_id' => 'nullable|string', + 'max_id' => 'nullable|string', + 'min_id' => 'nullable|string', + 'type' => 'nullable|in:accounts,hashtags,statuses', + 'exclude_unreviewed' => 'nullable', + 'resolve' => 'nullable', + 'limit' => 'nullable|integer|max:40', + 'offset' => 'nullable|integer', + 'following' => 'nullable' + ]); + + $mastodonMode = !$request->has('_pe'); + return $this->json(SearchApiV2Service::query($request, $mastodonMode)); + } + + /** + * GET /api/v2/streaming/config + * + * + * @return object + */ + public function getWebsocketConfig() + { + return config('broadcasting.default') === 'pusher' ? [ + 'host' => config('broadcasting.connections.pusher.options.host'), + 'port' => config('broadcasting.connections.pusher.options.port'), + 'key' => config('broadcasting.connections.pusher.key'), + 'cluster' => config('broadcasting.connections.pusher.options.cluster') + ] : []; + } + + /** + * POST /api/v2/media + * + * + * @return MediaTransformer + */ + public function mediaUploadV2(Request $request) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + + $this->validate($request, [ + 'file.*' => [ + 'required_without:file', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'file' => [ + 'required_without:file.*', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'filter_name' => 'nullable|string|max:24', + 'filter_class' => 'nullable|alpha_dash|max:24', + 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'), + 'replace_id' => 'sometimes' + ]); + + $user = $request->user(); + + if($user->last_active_at == null) { + return []; + } + + if(empty($request->file('file'))) { + return response('', 422); + } + + $limitKey = 'compose:rate-limit:media-upload:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); + + return $dailyLimit >= 250; + }); + abort_if($limitReached == true, 429); + + $profile = $user->profile; + + if(config_cache('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + + $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; + $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; + + $photo = $request->file('file'); + + $mimes = explode(',', config_cache('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } + + $storagePath = MediaPathService::get($user, 2); + $path = $photo->storePublicly($storagePath); + $hash = \hash_file('sha256', $photo); + $license = null; + $mime = $photo->getMimeType(); + + $settings = UserSetting::whereUserId($user->id)->first(); + + if($settings && !empty($settings->compose_settings)) { + $compose = $settings->compose_settings; + + if(isset($compose['default_license']) && $compose['default_license'] != 1) { + $license = $compose['default_license']; + } + } + + abort_if(MediaBlocklistService::exists($hash) == true, 451); + + if($request->has('replace_id')) { + $rpid = $request->input('replace_id'); + $removeMedia = Media::whereNull('status_id') + ->whereUserId($user->id) + ->whereProfileId($profile->id) + ->where('created_at', '>', now()->subHours(2)) + ->find($rpid); + if($removeMedia) { + MediaDeletePipeline::dispatch($removeMedia) + ->onQueue('mmo') + ->delay(now()->addMinutes(15)); + } + } + + $media = new Media(); + $media->status_id = null; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $mime; + $media->caption = $request->input('description'); + $media->filter_class = $filterClass; + $media->filter_name = $filterName; + if($license) { + $media->license = $license; + } + $media->save(); + + switch ($media->mime) { + case 'image/jpeg': + case 'image/png': + ImageOptimize::dispatch($media)->onQueue('mmo'); + break; + + case 'video/mp4': + VideoThumbnail::dispatch($media)->onQueue('mmo'); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; + } + + Cache::forget($limitKey); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($media, new MediaTransformer()); + $res = $fractal->createData($resource)->toArray(); + $res['preview_url'] = $media->url(). '?v=' . time(); + $res['url'] = null; + return $this->json($res, 202); + } +} diff --git a/app/Services/InstanceService.php b/app/Services/InstanceService.php index f044f1b27..c2a33a888 100644 --- a/app/Services/InstanceService.php +++ b/app/Services/InstanceService.php @@ -4,6 +4,7 @@ namespace App\Services; use Cache; use App\Instance; +use App\Util\Blurhash\Blurhash; class InstanceService { @@ -12,6 +13,7 @@ class InstanceService const CACHE_KEY_UNLISTED_DOMAINS = 'instances:unlisted:domains'; const CACHE_KEY_NSFW_DOMAINS = 'instances:auto_cw:domains'; const CACHE_KEY_STATS = 'pf:services:instances:stats'; + const CACHE_KEY_BANNER_BLURHASH = 'pf:services:instance:header-blurhash'; public static function getByDomain($domain) { @@ -78,4 +80,42 @@ class InstanceService return true; } + + public static function headerBlurhash() + { + return Cache::rememberForever(self::CACHE_KEY_BANNER_BLURHASH, function() { + if(str_ends_with(config_cache('app.banner_image'), 'headers/default.jpg')) { + return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt'; + } + $file = config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')); + + $image = imagecreatefromstring(file_get_contents($file)); + if(!$image) { + return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt'; + } + $width = imagesx($image); + $height = imagesy($image); + + $pixels = []; + for ($y = 0; $y < $height; ++$y) { + $row = []; + for ($x = 0; $x < $width; ++$x) { + $index = imagecolorat($image, $x, $y); + $colors = imagecolorsforindex($image, $index); + + $row[] = [$colors['red'], $colors['green'], $colors['blue']]; + } + $pixels[] = $row; + } + + $components_x = 4; + $components_y = 4; + $blurhash = Blurhash::encode($pixels, $components_x, $components_y); + if(strlen($blurhash) > 191) { + return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt'; + } + + return $blurhash; + }); + } } diff --git a/config/app.php b/config/app.php index 5ce57bcf6..5a848676e 100644 --- a/config/app.php +++ b/config/app.php @@ -105,6 +105,7 @@ return [ 'description' => 'Pixelfed is an image sharing platform, an ethical alternative to centralized platforms.', 'rules' => null, 'logo' => '/img/pixelfed-icon-color.svg', + 'banner_image' => '/storage/headers/default.jpg', /* |-------------------------------------------------------------------------- diff --git a/routes/api.php b/routes/api.php index b3746a85c..7e996df1d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -97,9 +97,10 @@ Route::group(['prefix' => 'api'], function() use($middleware) { }); Route::group(['prefix' => 'v2'], function() use($middleware) { - Route::get('search', 'Api\ApiV1Controller@searchV2')->middleware($middleware); - Route::post('media', 'Api\ApiV1Controller@mediaUploadV2')->middleware($middleware); - Route::get('streaming/config', 'Api\ApiV1Controller@getWebsocketConfig'); + Route::get('search', 'Api\ApiV2Controller@search')->middleware($middleware); + Route::post('media', 'Api\ApiV2Controller@mediaUploadV2')->middleware($middleware); + Route::get('streaming/config', 'Api\ApiV2Controller@getWebsocketConfig'); + Route::get('instance', 'Api\ApiV2Controller@instance'); }); Route::group(['prefix' => 'v1.1'], function() use($middleware) {