feat: upload editor images to image service
This commit is contained in:
@@ -65,8 +65,9 @@ public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_sec
|
||||
|
||||
플래그가 `false`이면 기존 `d_pic` 및 `IMGSVR=1` 동작을 유지합니다. `true`이면
|
||||
PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로
|
||||
이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다. 공유
|
||||
비밀값은 브라우저나 Cloudflare로 보내지 않습니다.
|
||||
이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다.
|
||||
국방·외교 등 TipTap 편집기 첨부 이미지도 같은 플래그로 `/uploads/core/` bind
|
||||
저장소로 전환됩니다. 공유 비밀값은 브라우저나 Cloudflare로 보내지 않습니다.
|
||||
|
||||
### 설치
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use sammo\AppConf;
|
||||
use sammo\Enums\APIRecoveryType;
|
||||
use sammo\KVStorage;
|
||||
use sammo\RootDB;
|
||||
use sammo\RemoteUserIconUploadClient;
|
||||
use sammo\TimeUtil;
|
||||
use sammo\UniqueConst;
|
||||
use sammo\Validator;
|
||||
@@ -60,22 +61,33 @@ class UploadImage extends \sammo\BaseAPI
|
||||
$imgName = hash_final($oMD);
|
||||
$imgFullName = "{$imgName}.{$extension}";
|
||||
|
||||
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
|
||||
$destPath = "{$destDir}/{$imgFullName}";
|
||||
$remotePath = null;
|
||||
if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
|
||||
try {
|
||||
RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData);
|
||||
$remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName);
|
||||
} catch (\Throwable $error) {
|
||||
error_log('Remote content image upload failed: ' . $error->getMessage());
|
||||
return '원격 이미지 저장소 업로드에 실패했습니다!';
|
||||
}
|
||||
} else {
|
||||
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
|
||||
$destPath = "{$destDir}/{$imgFullName}";
|
||||
|
||||
if (!file_exists($destPath)) {
|
||||
if (!file_exists($destDir)) {
|
||||
mkdir($destDir);
|
||||
}
|
||||
if (!is_dir($destDir)) {
|
||||
return '버그! 업로드 경로 확인!';
|
||||
}
|
||||
if (!is_writable($destDir)) {
|
||||
return '버그! 업로드 권한 확인!';
|
||||
}
|
||||
if (!file_exists($destPath)) {
|
||||
if (!file_exists($destDir)) {
|
||||
mkdir($destDir);
|
||||
}
|
||||
if (!is_dir($destDir)) {
|
||||
return '버그! 업로드 경로 확인!';
|
||||
}
|
||||
if (!is_writable($destDir)) {
|
||||
return '버그! 업로드 권한 확인!';
|
||||
}
|
||||
|
||||
if (!file_put_contents($destPath, $imageData)) {
|
||||
return '업로드에 실패했습니다!';
|
||||
if (!file_put_contents($destPath, $imageData)) {
|
||||
return '업로드에 실패했습니다!';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI
|
||||
|
||||
return [
|
||||
'result' => true,
|
||||
'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
|
||||
'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,38 @@ final class RemoteUserIconUploadClient
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function uploadConfigured(string $filename, string $contentType, string $body): array
|
||||
{
|
||||
[$baseUrl, $secret] = self::configuredBaseUrlAndSecret();
|
||||
return self::upload(
|
||||
"{$baseUrl}/v1/uploads/user-icons/core/{$filename}",
|
||||
'core',
|
||||
$secret,
|
||||
$contentType,
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function uploadContentConfigured(string $filename, string $contentType, string $body): array
|
||||
{
|
||||
[$baseUrl, $secret] = self::configuredBaseUrlAndSecret();
|
||||
return self::upload(
|
||||
"{$baseUrl}/v1/uploads/content/core/{$filename}",
|
||||
'core',
|
||||
$secret,
|
||||
$contentType,
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
public static function getConfiguredContentPublicUrl(string $filename): string
|
||||
{
|
||||
[$baseUrl] = self::configuredBaseUrlAndSecret();
|
||||
return "{$baseUrl}/uploads/core/{$filename}";
|
||||
}
|
||||
|
||||
/** @return array{string,string} */
|
||||
private static function configuredBaseUrlAndSecret(): array
|
||||
{
|
||||
if (!self::isConfiguredEnabled()
|
||||
|| !property_exists(ServConfig::class, 'remoteUserIconUploadPath')
|
||||
@@ -26,14 +58,7 @@ final class RemoteUserIconUploadClient
|
||||
$secretPath = ROOT . '/' . $secretPath;
|
||||
}
|
||||
$secret = trim((string)file_get_contents($secretPath));
|
||||
return self::upload(
|
||||
rtrim((string)ServConfig::$remoteUserIconUploadPath, '/')
|
||||
. '/v1/uploads/user-icons/core/' . $filename,
|
||||
'core',
|
||||
$secret,
|
||||
$contentType,
|
||||
$body
|
||||
);
|
||||
return [rtrim((string)ServConfig::$remoteUserIconUploadPath, '/'), $secret];
|
||||
}
|
||||
|
||||
/** @return array{headers:list<string>,requestId:string,expires:string} */
|
||||
@@ -53,7 +78,7 @@ final class RemoteUserIconUploadClient
|
||||
throw new \InvalidArgumentException('Image upload secret must be at least 32 characters');
|
||||
}
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if (!is_string($path) || !preg_match('#^/v1/uploads/user-icons/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$#', $path)) {
|
||||
if (!is_string($path) || !preg_match('#^/v1/uploads/(?:user-icons|content)/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$#', $path)) {
|
||||
throw new \InvalidArgumentException('Invalid image upload URL');
|
||||
}
|
||||
$expiresText = (string)($expires ?? time() + 60);
|
||||
@@ -110,7 +135,11 @@ final class RemoteUserIconUploadClient
|
||||
throw new \RuntimeException("Image upload rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error'));
|
||||
}
|
||||
$filename = basename((string)parse_url($url, PHP_URL_PATH));
|
||||
if (($decoded['path'] ?? null) !== "icons/users/{$client}/{$filename}") {
|
||||
$category = str_contains((string)parse_url($url, PHP_URL_PATH), '/content/') ? 'content' : 'user-icons';
|
||||
$expectedPath = $category === 'content'
|
||||
? "uploads/{$client}/{$filename}"
|
||||
: "icons/users/{$client}/{$filename}";
|
||||
if (($decoded['path'] ?? null) !== $expectedPath) {
|
||||
throw new \RuntimeException('Image upload returned an unexpected path');
|
||||
}
|
||||
return $decoded;
|
||||
|
||||
@@ -42,4 +42,18 @@ final class RemoteUserIconUploadClientTest extends TestCase
|
||||
'body'
|
||||
);
|
||||
}
|
||||
|
||||
public function testBuildRequestAcceptsScopedEditorContent(): void
|
||||
{
|
||||
$request = RemoteUserIconUploadClient::buildRequest(
|
||||
'https://sam-image.hided.net/v1/uploads/content/core/' . str_repeat('b', 32) . '.jpeg',
|
||||
'core',
|
||||
str_repeat('u', 32),
|
||||
'image/jpeg',
|
||||
"\xff\xd8\xffbody",
|
||||
1786012860,
|
||||
'core-content-1234'
|
||||
);
|
||||
self::assertStringContainsString('X-Image-Client: core', implode("\n", $request['headers']));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user