mirror of
https://github.com/lkddi/nexusphp.git
synced 2026-07-26 13:57:49 +08:00
Merge pull request #442 from fei152/feat/cloudflare-imgbed
feat: add CloudFlare ImgBed attachment driver
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '权限',
|
||||
|
||||
@@ -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