mirror of
https://github.com/lkddi/nexusphp.git
synced 2026-07-26 13:57:49 +08:00
feat: add CloudFlare ImgBed attachment driver
This commit is contained in:
@@ -501,7 +501,12 @@ class EditSetting extends Page implements HasForms
|
|||||||
$schema = [];
|
$schema = [];
|
||||||
$name = "$id.driver";
|
$name = "$id.driver";
|
||||||
$schema[] = Radio::make($name)
|
$schema[] = Radio::make($name)
|
||||||
->options(['local' => 'local', 'chevereto' => 'chevereto', 'lsky' => 'lsky'])
|
->options([
|
||||||
|
'local' => 'local',
|
||||||
|
'chevereto' => 'chevereto',
|
||||||
|
'lsky' => 'lsky',
|
||||||
|
'cloudflare_imgbed' => 'CloudFlare ImgBed',
|
||||||
|
])
|
||||||
->inline(true)
|
->inline(true)
|
||||||
->label(__("label.setting.$name"))
|
->label(__("label.setting.$name"))
|
||||||
->helperText(__("label.setting.{$name}_help"))
|
->helperText(__("label.setting.{$name}_help"))
|
||||||
@@ -519,6 +524,8 @@ class EditSetting extends Page implements HasForms
|
|||||||
$field = "upload_token";
|
$field = "upload_token";
|
||||||
$driverSchemas[] = TextInput::make("$driverId.$field")
|
$driverSchemas[] = TextInput::make("$driverId.$field")
|
||||||
->label(__("label.setting.$id.$field"))
|
->label(__("label.setting.$id.$field"))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
;
|
;
|
||||||
$field = "base_url";
|
$field = "base_url";
|
||||||
$driverSchemas[] = TextInput::make("$driverId.$field")
|
$driverSchemas[] = TextInput::make("$driverId.$field")
|
||||||
@@ -539,6 +546,8 @@ class EditSetting extends Page implements HasForms
|
|||||||
$field = "upload_token";
|
$field = "upload_token";
|
||||||
$driverSchemas[] = TextInput::make("$driverId.$field")
|
$driverSchemas[] = TextInput::make("$driverId.$field")
|
||||||
->label(__("label.setting.$id.$field"))
|
->label(__("label.setting.$id.$field"))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
;
|
;
|
||||||
$field = "base_url";
|
$field = "base_url";
|
||||||
$driverSchemas[] = TextInput::make("$driverId.$field")
|
$driverSchemas[] = TextInput::make("$driverId.$field")
|
||||||
@@ -547,6 +556,53 @@ class EditSetting extends Page implements HasForms
|
|||||||
$driverSection = Section::make($driverName)->schema($driverSchemas);
|
$driverSection = Section::make($driverName)->schema($driverSchemas);
|
||||||
$schema[] = $driverSection;
|
$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;
|
return $schema;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class Chevereto extends Storage {
|
|||||||
{
|
{
|
||||||
$api = get_setting("image_hosting_chevereto.upload_api_endpoint");
|
$api = get_setting("image_hosting_chevereto.upload_api_endpoint");
|
||||||
$token = get_setting("image_hosting_chevereto.upload_token");
|
$token = get_setting("image_hosting_chevereto.upload_token");
|
||||||
$logPrefix = "filepath: $filepath, api: $api, token: $token";
|
$logPrefix = "filepath: $filepath, api: $api";
|
||||||
$httpClient = new \GuzzleHttp\Client();
|
$httpClient = new \GuzzleHttp\Client();
|
||||||
$response = $httpClient->request('POST', $api, [
|
$response = $httpClient->request('POST', $api, [
|
||||||
'headers' => [
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ class Lsky extends Storage {
|
|||||||
{
|
{
|
||||||
$api = get_setting("image_hosting_lsky.upload_api_endpoint");
|
$api = get_setting("image_hosting_lsky.upload_api_endpoint");
|
||||||
$token = get_setting("image_hosting_lsky.upload_token");
|
$token = get_setting("image_hosting_lsky.upload_token");
|
||||||
$logPrefix = "filepath: $filepath, api: $api, token: $token";
|
$logPrefix = "filepath: $filepath, api: $api";
|
||||||
$httpClient = new \GuzzleHttp\Client();
|
$httpClient = new \GuzzleHttp\Client();
|
||||||
$response = $httpClient->request('POST', $api, [
|
$response = $httpClient->request('POST', $api, [
|
||||||
'headers' => [
|
'headers' => [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
namespace Nexus\Attachment;
|
namespace Nexus\Attachment;
|
||||||
|
|
||||||
use Nexus\Attachment\Drivers\Chevereto;
|
use Nexus\Attachment\Drivers\Chevereto;
|
||||||
|
use Nexus\Attachment\Drivers\CloudflareImgBed;
|
||||||
use Nexus\Attachment\Drivers\Local;
|
use Nexus\Attachment\Drivers\Local;
|
||||||
use Nexus\Attachment\Drivers\Lsky;
|
use Nexus\Attachment\Drivers\Lsky;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ abstract class Storage {
|
|||||||
const DRIVER_LOCAL = 'local';
|
const DRIVER_LOCAL = 'local';
|
||||||
const DRIVER_CHEVERETO = 'chevereto';
|
const DRIVER_CHEVERETO = 'chevereto';
|
||||||
const DRIVER_LSKY = 'lsky';
|
const DRIVER_LSKY = 'lsky';
|
||||||
|
const DRIVER_CLOUDFLARE_IMGBED = 'cloudflare_imgbed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* upload to remote and return full url
|
* upload to remote and return full url
|
||||||
@@ -70,6 +72,8 @@ abstract class Storage {
|
|||||||
$result = new Chevereto();
|
$result = new Chevereto();
|
||||||
} else if ($driver == self::DRIVER_LSKY) {
|
} else if ($driver == self::DRIVER_LSKY) {
|
||||||
$result = new Lsky();
|
$result = new Lsky();
|
||||||
|
} else if ($driver == self::DRIVER_CLOUDFLARE_IMGBED) {
|
||||||
|
$result = new CloudflareImgBed();
|
||||||
} else if ($driver == self::DRIVER_LOCAL) {
|
} else if ($driver == self::DRIVER_LOCAL) {
|
||||||
$result = new Local();
|
$result = new Local();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ return [
|
|||||||
'upload_api_endpoint' => 'Upload interface address',
|
'upload_api_endpoint' => 'Upload interface address',
|
||||||
'base_url' => 'Image URL prefix',
|
'base_url' => 'Image URL prefix',
|
||||||
'upload_token' => 'Upload token',
|
'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' => [
|
'permission' => [
|
||||||
'tab_header' => 'Permission',
|
'tab_header' => 'Permission',
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ return [
|
|||||||
'upload_api_endpoint' => '上传接口地址',
|
'upload_api_endpoint' => '上传接口地址',
|
||||||
'base_url' => '图片 URL 前缀',
|
'base_url' => '图片 URL 前缀',
|
||||||
'upload_token' => '上传令牌',
|
'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' => [
|
'permission' => [
|
||||||
'tab_header' => '权限',
|
'tab_header' => '权限',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user