diff --git a/README.md b/README.md index 40441df2..e31fe02a 100644 --- a/README.md +++ b/README.md @@ -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로 보내지 않습니다. ### 설치 diff --git a/hwe/sammo/API/Misc/UploadImage.php b/hwe/sammo/API/Misc/UploadImage.php index 36e06043..c58f419a 100644 --- a/hwe/sammo/API/Misc/UploadImage.php +++ b/hwe/sammo/API/Misc/UploadImage.php @@ -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, ]; } } diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php index cc4e3b02..1ae7aa28 100644 --- a/src/sammo/RemoteUserIconUploadClient.php +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -12,6 +12,38 @@ final class RemoteUserIconUploadClient /** @return array */ 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 */ + 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,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; diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php index 271f2ad8..a62bfe60 100644 --- a/tests/RemoteUserIconUploadClientTest.php +++ b/tests/RemoteUserIconUploadClientTest.php @@ -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'])); + } }