From e8c44377ecea163ef651f521d1b2612397ab42de Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Nov 2025 17:58:02 +1300 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20ApiV2Controller=20=E2=86=92=20f?= =?UTF-8?q?ocused=20V2=20controllers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split ApiV2Controller into 4 focused controllers: - V2/InstanceController: instance endpoint - V2/SearchController: search endpoint - V2/MediaController: media upload endpoint - V2/StreamingController: websocket config endpoint - Updated routes to point to new controllers - Memory optimization: 342 lines → 4 focused controllers (~50-150 lines each) This resolves PHPStan memory issues for V2 API endpoints. --- .../Controllers/Api/V2/InstanceController.php | 124 ++++++++++++ .../Controllers/Api/V2/MediaController.php | 181 ++++++++++++++++++ .../Controllers/Api/V2/SearchController.php | 55 ++++++ .../Api/V2/StreamingController.php | 32 ++++ routes/api.php | 8 +- 5 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/V2/InstanceController.php create mode 100644 app/Http/Controllers/Api/V2/MediaController.php create mode 100644 app/Http/Controllers/Api/V2/SearchController.php create mode 100644 app/Http/Controllers/Api/V2/StreamingController.php diff --git a/app/Http/Controllers/Api/V2/InstanceController.php b/app/Http/Controllers/Api/V2/InstanceController.php new file mode 100644 index 000000000..f10608a82 --- /dev/null +++ b/app/Http/Controllers/Api/V2/InstanceController.php @@ -0,0 +1,124 @@ +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 = Cache::remember('api:v2:instance-data-response-v2', 1800, function () use ($contact, $rules) { + return [ + 'domain' => config('pixelfed.domain.app'), + 'title' => config_cache('app.name'), + 'version' => '3.5.3 (compatible; Pixelfed '.config('pixelfed.version').')', + 'source_url' => 'https://github.com/pixelfed/pixelfed', + 'description' => config_cache('app.short_description'), + 'usage' => [ + 'users' => [ + 'active_month' => (int) Nodeinfo::activeUsersMonthly(), + ], + ], + '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' => null, + 'status' => null, + ], + 'vapid' => [ + 'public_key' => config('webpush.vapid.public_key'), + ], + 'accounts' => [ + 'max_featured_tags' => 0, + ], + 'statuses' => [ + 'max_characters' => (int) config_cache('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' => 2073600, + 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, + 'video_frame_rate_limit' => 120, + 'video_matrix_limit' => 2073600, + ], + 'polls' => [ + 'max_options' => 0, + 'max_characters_per_option' => 0, + 'min_expiration' => 0, + 'max_expiration' => 0, + ], + 'translation' => [ + 'enabled' => false, + ], + ], + 'registrations' => [ + 'enabled' => null, + 'approval_required' => false, + 'message' => null, + 'url' => null, + ], + 'contact' => [ + 'email' => config('instance.email'), + 'account' => $contact, + ], + 'rules' => $rules, + ]; + }); + + $res['registrations']['enabled'] = (bool) config_cache('pixelfed.open_registration'); + $res['registrations']['approval_required'] = (bool) config_cache('instance.curated_registration.enabled'); + + return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V2/MediaController.php b/app/Http/Controllers/Api/V2/MediaController.php new file mode 100644 index 000000000..bfbc75f95 --- /dev/null +++ b/app/Http/Controllers/Api/V2/MediaController.php @@ -0,0 +1,181 @@ +json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + + /** + * POST /api/v2/media + * + * + * @return MediaTransformer + */ + public function mediaUploadV2(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('write'), 403); + + $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 >= 1250; + }); + abort_if($limitReached == true, 429); + + $profile = $user->profile; + + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($updatedAccountSize >= $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; + + $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/jpg': + case 'image/jpeg': + case 'image/png': + case 'image/webp': + case 'image/heic': + case 'image/avif': + 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; + } + + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + + 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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V2/SearchController.php b/app/Http/Controllers/Api/V2/SearchController.php new file mode 100644 index 000000000..695ea0463 --- /dev/null +++ b/app/Http/Controllers/Api/V2/SearchController.php @@ -0,0 +1,55 @@ +json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + + /** + * GET /api/v2/search + * + * + * @return array + */ + public function search(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('read'), 403); + + $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', + ]); + + if ($request->user()->has_roles && ! UserRoleService::can('can-view-discover', $request->user()->id)) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [], + ]; + } + + $mastodonMode = ! $request->has('_pe'); + + return $this->json(SearchApiV2Service::query($request, $mastodonMode)); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V2/StreamingController.php b/app/Http/Controllers/Api/V2/StreamingController.php new file mode 100644 index 000000000..7c5315813 --- /dev/null +++ b/app/Http/Controllers/Api/V2/StreamingController.php @@ -0,0 +1,32 @@ +json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + + /** + * 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'), + ] : []; + } +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 8133db7a4..8f69986c4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -184,10 +184,10 @@ Route::group(['prefix' => 'api'], function () use ($middleware) { }); Route::group(['prefix' => 'v2'], function () use ($middleware) { - 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::get('search', 'Api\V2\SearchController@search')->middleware($middleware); + Route::post('media', 'Api\V2\MediaController@mediaUploadV2')->middleware($middleware); + Route::get('streaming/config', 'Api\V2\StreamingController@getWebsocketConfig'); + Route::get('instance', 'Api\V2\InstanceController@instance'); Route::get('filters', 'CustomFilterController@index')->middleware($middleware); Route::get('filters/{id}', 'CustomFilterController@show')->middleware($middleware); From 6409214f07dfbf89c80201382e68be124972d0dd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 6 Nov 2025 18:14:35 +1300 Subject: [PATCH 2/2] remove old V2Controller.php --- app/Http/Controllers/Api/ApiV2Controller.php | 342 ------------------- 1 file changed, 342 deletions(-) delete mode 100644 app/Http/Controllers/Api/ApiV2Controller.php diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php deleted file mode 100644 index 92a6c7313..000000000 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ /dev/null @@ -1,342 +0,0 @@ -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 = Cache::remember('api:v2:instance-data-response-v2', 1800, function () use ($contact, $rules) { - return [ - 'domain' => config('pixelfed.domain.app'), - 'title' => config_cache('app.name'), - 'version' => '3.5.3 (compatible; Pixelfed '.config('pixelfed.version').')', - 'source_url' => 'https://github.com/pixelfed/pixelfed', - 'description' => config_cache('app.short_description'), - 'usage' => [ - 'users' => [ - 'active_month' => (int) Nodeinfo::activeUsersMonthly(), - ], - ], - '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' => null, - 'status' => null, - ], - 'vapid' => [ - 'public_key' => config('webpush.vapid.public_key'), - ], - 'accounts' => [ - 'max_featured_tags' => 0, - ], - 'statuses' => [ - 'max_characters' => (int) config_cache('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' => 2073600, - 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, - 'video_frame_rate_limit' => 120, - 'video_matrix_limit' => 2073600, - ], - 'polls' => [ - 'max_options' => 0, - 'max_characters_per_option' => 0, - 'min_expiration' => 0, - 'max_expiration' => 0, - ], - 'translation' => [ - 'enabled' => false, - ], - ], - 'registrations' => [ - 'enabled' => null, - 'approval_required' => false, - 'message' => null, - 'url' => null, - ], - 'contact' => [ - 'email' => config('instance.email'), - 'account' => $contact, - ], - 'rules' => $rules, - ]; - }); - - $res['registrations']['enabled'] = (bool) config_cache('pixelfed.open_registration'); - $res['registrations']['approval_required'] = (bool) config_cache('instance.curated_registration.enabled'); - - return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); - } - - /** - * GET /api/v2/search - * - * - * @return array - */ - public function search(Request $request) - { - abort_if(! $request->user() || ! $request->user()->token(), 403); - abort_unless($request->user()->tokenCan('read'), 403); - - $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', - ]); - - if ($request->user()->has_roles && ! UserRoleService::can('can-view-discover', $request->user()->id)) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [], - ]; - } - - $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() || ! $request->user()->token(), 403); - abort_unless($request->user()->tokenCan('write'), 403); - - $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 >= 1250; - }); - abort_if($limitReached == true, 429); - - $profile = $user->profile; - - $accountSize = UserStorageService::get($user->id); - abort_if($accountSize === -1, 403, 'Invalid request.'); - $photo = $request->file('file'); - $fileSize = $photo->getSize(); - $sizeInKbs = (int) ceil($fileSize / 1000); - $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; - - if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { - $limit = (int) config_cache('pixelfed.max_account_size'); - if ($updatedAccountSize >= $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; - - $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/jpg': - case 'image/jpeg': - case 'image/png': - case 'image/webp': - case 'image/heic': - case 'image/avif': - 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; - } - - $user->storage_used = (int) $updatedAccountSize; - $user->storage_used_updated_at = now(); - $user->save(); - - 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); - } -}