Merge pull request #442 from fei152/feat/cloudflare-imgbed

feat: add CloudFlare ImgBed attachment driver
This commit is contained in:
xiaomlove
2026-07-26 03:04:55 +07:00
committed by GitHub
8 changed files with 327 additions and 3 deletions
@@ -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;
}
+1 -1
View File
@@ -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' => [
@@ -0,0 +1,145 @@
<?php
namespace Nexus\Attachment\Drivers;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Nexus\Attachment\Storage;
class CloudflareImgBed extends Storage
{
private ?ClientInterface $httpClient;
public function __construct(?ClientInterface $httpClient = null)
{
$this->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);
}
}
+1 -1
View File
@@ -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' => [
+4
View File
@@ -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();
}
+9
View File
@@ -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',
+9
View File
@@ -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' => '权限',
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Tests\Unit;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Nexus\Attachment\Drivers\CloudflareImgBed;
use PHPUnit\Framework\TestCase;
class CloudflareImgBedTest extends TestCase
{
private string $imagePath;
protected function setUp(): void
{
parent::setUp();
$this->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;
}
};
}
}