From 5b83fa172265f278c9ed1fb56011319dfc321dd9 Mon Sep 17 00:00:00 2001 From: fei152 <62457585+fei152@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:15:04 +0800 Subject: [PATCH] feat: add CloudFlare ImgBed attachment driver --- .../SettingResource/Pages/EditSetting.php | 58 ++++++- nexus/Attachment/Drivers/Chevereto.php | 2 +- nexus/Attachment/Drivers/CloudflareImgBed.php | 145 ++++++++++++++++++ nexus/Attachment/Drivers/Lsky.php | 2 +- nexus/Attachment/Storage.php | 4 + resources/lang/en/label.php | 9 ++ resources/lang/zh_CN/label.php | 9 ++ tests/Unit/CloudflareImgBedTest.php | 101 ++++++++++++ 8 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 nexus/Attachment/Drivers/CloudflareImgBed.php create mode 100644 tests/Unit/CloudflareImgBedTest.php diff --git a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php index d3c731de..fce4bfc1 100644 --- a/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php +++ b/app/Filament/Resources/System/SettingResource/Pages/EditSetting.php @@ -501,7 +501,12 @@ class EditSetting extends Page implements HasForms $schema = []; $name = "$id.driver"; $schema[] = Radio::make($name) - ->options(['local' => 'local', 'chevereto' => 'chevereto', 'lsky' => 'lsky']) + ->options([ + 'local' => 'local', + 'chevereto' => 'chevereto', + 'lsky' => 'lsky', + 'cloudflare_imgbed' => 'CloudFlare ImgBed', + ]) ->inline(true) ->label(__("label.setting.$name")) ->helperText(__("label.setting.{$name}_help")) @@ -519,6 +524,8 @@ class EditSetting extends Page implements HasForms $field = "upload_token"; $driverSchemas[] = TextInput::make("$driverId.$field") ->label(__("label.setting.$id.$field")) + ->password() + ->revealable() ; $field = "base_url"; $driverSchemas[] = TextInput::make("$driverId.$field") @@ -539,6 +546,8 @@ class EditSetting extends Page implements HasForms $field = "upload_token"; $driverSchemas[] = TextInput::make("$driverId.$field") ->label(__("label.setting.$id.$field")) + ->password() + ->revealable() ; $field = "base_url"; $driverSchemas[] = TextInput::make("$driverId.$field") @@ -547,6 +556,53 @@ class EditSetting extends Page implements HasForms $driverSection = Section::make($driverName)->schema($driverSchemas); $schema[] = $driverSection; + //CloudFlare ImgBed + $driverName = "cloudflare_imgbed"; + $driverId = sprintf("%s_%s", $id, $driverName); + $driverSchemas = []; + $field = "upload_api_endpoint"; + $driverSchemas[] = TextInput::make("$driverId.$field") + ->label(__("label.setting.$id.$field")) + ->helperText(__("label.setting.$id.cloudflare_imgbed_endpoint_help")) + ; + $field = "upload_token"; + $driverSchemas[] = TextInput::make("$driverId.$field") + ->label(__("label.setting.$id.$field")) + ->helperText(__("label.setting.$id.cloudflare_imgbed_token_help")) + ->password() + ->revealable() + ; + $field = "base_url"; + $driverSchemas[] = TextInput::make("$driverId.$field") + ->label(__("label.setting.$id.$field")) + ->helperText(__("label.setting.$id.cloudflare_imgbed_base_url_help")) + ; + $field = "upload_channel"; + $driverSchemas[] = Select::make("$driverId.$field") + ->options([ + 'telegram' => 'Telegram', + 'cfr2' => 'Cloudflare R2', + 's3' => 'S3', + 'discord' => 'Discord', + 'huggingface' => 'Hugging Face', + 'webdav' => 'WebDAV', + ]) + ->placeholder(__("label.setting.$id.upload_channel_default")) + ->label(__("label.setting.$id.$field")) + ; + $field = "channel_name"; + $driverSchemas[] = TextInput::make("$driverId.$field") + ->label(__("label.setting.$id.$field")) + ->helperText(__("label.setting.$id.channel_name_help")) + ; + $field = "upload_folder"; + $driverSchemas[] = TextInput::make("$driverId.$field") + ->label(__("label.setting.$id.$field")) + ->helperText(__("label.setting.$id.upload_folder_help")) + ; + $driverSection = Section::make('CloudFlare ImgBed')->schema($driverSchemas); + $schema[] = $driverSection; + return $schema; } diff --git a/nexus/Attachment/Drivers/Chevereto.php b/nexus/Attachment/Drivers/Chevereto.php index 4b9c5920..ebeae2fa 100644 --- a/nexus/Attachment/Drivers/Chevereto.php +++ b/nexus/Attachment/Drivers/Chevereto.php @@ -10,7 +10,7 @@ class Chevereto extends Storage { { $api = get_setting("image_hosting_chevereto.upload_api_endpoint"); $token = get_setting("image_hosting_chevereto.upload_token"); - $logPrefix = "filepath: $filepath, api: $api, token: $token"; + $logPrefix = "filepath: $filepath, api: $api"; $httpClient = new \GuzzleHttp\Client(); $response = $httpClient->request('POST', $api, [ 'headers' => [ diff --git a/nexus/Attachment/Drivers/CloudflareImgBed.php b/nexus/Attachment/Drivers/CloudflareImgBed.php new file mode 100644 index 00000000..aec11559 --- /dev/null +++ b/nexus/Attachment/Drivers/CloudflareImgBed.php @@ -0,0 +1,145 @@ +httpClient = $httpClient; + } + + function upload(string $filepath): string + { + $api = trim((string) $this->setting('upload_api_endpoint')); + if ($api === '') { + throw new \RuntimeException('CloudFlare ImgBed upload endpoint is required.'); + } + + $uri = new Uri($api); + if (!in_array(strtolower($uri->getScheme()), ['http', 'https'], true) || $uri->getHost() === '') { + throw new \RuntimeException('Invalid CloudFlare ImgBed upload endpoint.'); + } + + $uri = Uri::withQueryValue($uri, 'returnFormat', 'full'); + $querySettings = [ + 'uploadChannel' => 'upload_channel', + 'channelName' => 'channel_name', + 'uploadFolder' => 'upload_folder', + ]; + foreach ($querySettings as $queryName => $settingName) { + $value = trim((string) $this->setting($settingName)); + if ($value !== '') { + $uri = Uri::withQueryValue($uri, $queryName, $value); + } + } + + $headers = []; + $token = trim((string) $this->setting('upload_token')); + if ($token !== '') { + $headers['Authorization'] = "Bearer $token"; + } + + try { + $response = $this->httpClient()->request('POST', $uri, [ + 'headers' => $headers, + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => Psr7\Utils::tryFopen($filepath, 'r'), + 'filename' => basename($filepath), + ], + ], + 'connect_timeout' => 10, + 'timeout' => 60, + 'http_errors' => false, + ]); + } catch (GuzzleException $e) { + do_log(sprintf('CloudFlare ImgBed upload request failed: %s', $e->getMessage()), 'error'); + throw new \RuntimeException('CloudFlare ImgBed upload request failed.', 0, $e); + } + + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + if ($statusCode < 200 || $statusCode >= 300) { + do_log(sprintf( + 'CloudFlare ImgBed upload failed, status code: %d, body: %s', + $statusCode, + mb_substr($body, 0, 2000) + ), 'error'); + throw new \RuntimeException("CloudFlare ImgBed upload failed, status code $statusCode."); + } + + try { + $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + do_log('CloudFlare ImgBed returned invalid JSON.', 'error'); + throw new \RuntimeException('CloudFlare ImgBed returned invalid JSON.', 0, $e); + } + + $first = is_array($result) ? ($result[0] ?? null) : null; + if (!is_array($first)) { + throw new \RuntimeException('CloudFlare ImgBed response does not contain an uploaded file.'); + } + + $url = trim((string) ($first['publicUrl'] ?? '')); + if ($url === '') { + $url = trim((string) ($first['src'] ?? '')); + } + if ($url === '') { + throw new \RuntimeException('CloudFlare ImgBed response does not contain a file URL.'); + } + + return $this->absoluteUrl($uri, $url); + } + + function getBaseUrl(): string + { + $baseUrl = trim((string) $this->setting('base_url')); + if ($baseUrl !== '') { + return rtrim($baseUrl, '/'); + } + + $api = trim((string) $this->setting('upload_api_endpoint')); + if ($api === '') { + return ''; + } + $uri = new Uri($api); + return (string) $uri->withPath('')->withQuery('')->withFragment(''); + } + + function getDriverName(): string + { + return static::DRIVER_CLOUDFLARE_IMGBED; + } + + protected function setting(string $name, mixed $default = ''): mixed + { + return get_setting("image_hosting_cloudflare_imgbed.$name", $default); + } + + private function httpClient(): ClientInterface + { + return $this->httpClient ??= new Client(); + } + + private function absoluteUrl(Uri $endpoint, string $url): string + { + $result = new Uri($url); + if ($result->getScheme() !== '') { + return (string) $result; + } + + $origin = $endpoint->withPath('/')->withQuery('')->withFragment(''); + return (string) UriResolver::resolve($origin, $result); + } +} diff --git a/nexus/Attachment/Drivers/Lsky.php b/nexus/Attachment/Drivers/Lsky.php index 27f3fa23..bac57ba4 100644 --- a/nexus/Attachment/Drivers/Lsky.php +++ b/nexus/Attachment/Drivers/Lsky.php @@ -10,7 +10,7 @@ class Lsky extends Storage { { $api = get_setting("image_hosting_lsky.upload_api_endpoint"); $token = get_setting("image_hosting_lsky.upload_token"); - $logPrefix = "filepath: $filepath, api: $api, token: $token"; + $logPrefix = "filepath: $filepath, api: $api"; $httpClient = new \GuzzleHttp\Client(); $response = $httpClient->request('POST', $api, [ 'headers' => [ diff --git a/nexus/Attachment/Storage.php b/nexus/Attachment/Storage.php index 28badbd9..e3624816 100644 --- a/nexus/Attachment/Storage.php +++ b/nexus/Attachment/Storage.php @@ -2,6 +2,7 @@ namespace Nexus\Attachment; use Nexus\Attachment\Drivers\Chevereto; +use Nexus\Attachment\Drivers\CloudflareImgBed; use Nexus\Attachment\Drivers\Local; use Nexus\Attachment\Drivers\Lsky; @@ -12,6 +13,7 @@ abstract class Storage { const DRIVER_LOCAL = 'local'; const DRIVER_CHEVERETO = 'chevereto'; const DRIVER_LSKY = 'lsky'; + const DRIVER_CLOUDFLARE_IMGBED = 'cloudflare_imgbed'; /** * upload to remote and return full url @@ -70,6 +72,8 @@ abstract class Storage { $result = new Chevereto(); } else if ($driver == self::DRIVER_LSKY) { $result = new Lsky(); + } else if ($driver == self::DRIVER_CLOUDFLARE_IMGBED) { + $result = new CloudflareImgBed(); } else if ($driver == self::DRIVER_LOCAL) { $result = new Local(); } diff --git a/resources/lang/en/label.php b/resources/lang/en/label.php index def49dc2..def2f1a1 100644 --- a/resources/lang/en/label.php +++ b/resources/lang/en/label.php @@ -185,6 +185,15 @@ return [ 'upload_api_endpoint' => 'Upload interface address', 'base_url' => 'Image URL prefix', 'upload_token' => 'Upload token', + 'upload_channel' => 'Upload channel', + 'upload_channel_default' => 'Use the CloudFlare ImgBed default', + 'channel_name' => 'Channel name', + 'channel_name_help' => 'Optional. Selects a named channel when the same storage type has multiple channels.', + 'upload_folder' => 'Upload folder', + 'upload_folder_help' => 'Optional relative path, for example nexusphp/images.', + 'cloudflare_imgbed_endpoint_help' => 'Enter the complete /upload endpoint, for example https://img.example.com/upload.', + 'cloudflare_imgbed_token_help' => 'Use a CloudFlare ImgBed API Token with upload permission.', + 'cloudflare_imgbed_base_url_help' => 'Optional. Image URL prefix used to store relative paths. The upload endpoint origin is used when empty.', ], 'permission' => [ 'tab_header' => 'Permission', diff --git a/resources/lang/zh_CN/label.php b/resources/lang/zh_CN/label.php index 5cf33028..b5cb72ba 100644 --- a/resources/lang/zh_CN/label.php +++ b/resources/lang/zh_CN/label.php @@ -225,6 +225,15 @@ return [ 'upload_api_endpoint' => '上传接口地址', 'base_url' => '图片 URL 前缀', 'upload_token' => '上传令牌', + 'upload_channel' => '上传渠道', + 'upload_channel_default' => '使用 CloudFlare ImgBed 默认渠道', + 'channel_name' => '渠道名称', + 'channel_name_help' => '可选。同一种存储配置了多个渠道时,用它指定渠道名称。', + 'upload_folder' => '上传目录', + 'upload_folder_help' => '可选的相对目录,例如 nexusphp/images。', + 'cloudflare_imgbed_endpoint_help' => '填写完整的 /upload 接口,例如 https://img.example.com/upload。', + 'cloudflare_imgbed_token_help' => '使用具有 upload 权限的 CloudFlare ImgBed API Token。', + 'cloudflare_imgbed_base_url_help' => '可选,用于把图片地址保存为相对路径;留空时从上传接口地址自动获取。', ], 'permission' => [ 'tab_header' => '权限', diff --git a/tests/Unit/CloudflareImgBedTest.php b/tests/Unit/CloudflareImgBedTest.php new file mode 100644 index 00000000..bc9b7ce4 --- /dev/null +++ b/tests/Unit/CloudflareImgBedTest.php @@ -0,0 +1,101 @@ +imagePath = tempnam(sys_get_temp_dir(), 'imgbed_'); + file_put_contents($this->imagePath, 'image contents'); + } + + protected function tearDown(): void + { + @unlink($this->imagePath); + parent::tearDown(); + } + + public function testUploadUsesApiTokenAndConfiguredChannel(): void + { + $history = []; + $driver = $this->driver( + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + [ + 'src' => '/file/generated.jpg', + 'publicUrl' => 'https://cdn.example.com/generated.jpg', + ], + ])), + [ + 'upload_api_endpoint' => 'https://img.example.com/upload?existing=value', + 'upload_token' => 'secret-token', + 'base_url' => 'https://cdn.example.com/', + 'upload_channel' => 'cfr2', + 'channel_name' => 'primary', + 'upload_folder' => 'nexusphp/images', + ], + $history + ); + + $url = $driver->upload($this->imagePath); + + self::assertSame('https://cdn.example.com/generated.jpg', $url); + self::assertSame('https://cdn.example.com', $driver->getBaseUrl()); + self::assertSame('cloudflare_imgbed', $driver->getDriverName()); + self::assertCount(1, $history); + + $request = $history[0]['request']; + self::assertSame('Bearer secret-token', $request->getHeaderLine('Authorization')); + parse_str($request->getUri()->getQuery(), $query); + self::assertSame('value', $query['existing']); + self::assertSame('full', $query['returnFormat']); + self::assertSame('cfr2', $query['uploadChannel']); + self::assertSame('primary', $query['channelName']); + self::assertSame('nexusphp/images', $query['uploadFolder']); + } + + public function testUploadResolvesRelativeSrcAgainstEndpointOrigin(): void + { + $history = []; + $driver = $this->driver( + new Response(200, ['Content-Type' => 'application/json'], '[{"src":"/file/generated.jpg","publicUrl":""}]'), + ['upload_api_endpoint' => 'https://img.example.com/upload'], + $history + ); + + self::assertSame('https://img.example.com/file/generated.jpg', $driver->upload($this->imagePath)); + self::assertSame('https://img.example.com', $driver->getBaseUrl()); + self::assertSame('', $history[0]['request']->getHeaderLine('Authorization')); + } + + private function driver(Response $response, array $settings, array &$history): CloudflareImgBed + { + $mock = new MockHandler([$response]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + $client = new Client(['handler' => $stack]); + + return new class($client, $settings) extends CloudflareImgBed { + public function __construct(Client $client, private readonly array $settings) + { + parent::__construct($client); + } + + protected function setting(string $name, mixed $default = ''): mixed + { + return $this->settings[$name] ?? $default; + } + }; + } +}