feat: upload editor images to image service

This commit is contained in:
2026-08-06 15:53:06 +00:00
parent b095fb4bf1
commit eeb931d3f0
4 changed files with 83 additions and 27 deletions
+3 -2
View File
@@ -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로 보내지 않습니다.
### 설치
+27 -15
View File
@@ -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,
];
}
}
+39 -10
View File
@@ -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;
+14
View File
@@ -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']));
}
}