From b095fb4bf1b079f8669d9579cdac768d21d4d87d Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 15:43:44 +0000 Subject: [PATCH] feat: add flagged remote user icon upload --- README.md | 17 ++++ f_install/j_setup_db.php | 3 + f_install/j_update_js_css_path.php | 14 ++- f_install/templates/ServConfig.orig.php | 29 ++++++ i_entrance/j_icon_change.php | 28 +++++- src/sammo/RemoteUserIconUploadClient.php | 118 +++++++++++++++++++++++ tests/RemoteUserIconUploadClientTest.php | 45 +++++++++ 7 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/sammo/RemoteUserIconUploadClient.php create mode 100644 tests/RemoteUserIconUploadClientTest.php diff --git a/README.md b/README.md index 8723dbdb..40441df2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,23 @@ IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \ php scripts/sync-image-repository.php ``` +Ref의 사용자 아이콘 원격 업로드 구현은 기본적으로 꺼져 있습니다. 이미지 서버의 +`image_upload_core_secret`과 동일한 값을 Git 제외 파일 +`d_setting/image_upload_core_secret`에 저장한 뒤 실제 +`d_setting/ServConfig.php`에서 다음 값만 변경하면 기존 화면을 그대로 둔 채 +원격 bind 저장소로 전환됩니다. + +```php +public static $remoteUserIconUploadEnabled = true; +public static $remoteUserIconUploadPath = 'https://sam-image.hided.net'; +public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_secret'; +``` + +플래그가 `false`이면 기존 `d_pic` 및 `IMGSVR=1` 동작을 유지합니다. `true`이면 +PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로 +이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다. 공유 +비밀값은 브라우저나 Cloudflare로 보내지 않습니다. + ### 설치 이후 해당 경로를 웹 브라우저를 통해 접근하여 설치를 진행할 수 있습니다. diff --git a/f_install/j_setup_db.php b/f_install/j_setup_db.php index 18c22d41..88044563 100644 --- a/f_install/j_setup_db.php +++ b/f_install/j_setup_db.php @@ -212,6 +212,9 @@ $result = Util::generateFileUsingSimpleTemplate( 'gameImagePath' => $gameImagePath, 'imageRequestPath' => $imageRequestPath, 'imageRequestKey' => $imageRequestKey, + 'remoteUserIconUploadEnabled' => 'false', + 'remoteUserIconUploadPath' => 'https://sam-image.hided.net', + 'remoteUserIconUploadSecretFile' => 'd_setting/image_upload_core_secret', 'serverList' => [ ['che', '체', 'white'], ['kwe', '퀘', 'yellow'], diff --git a/f_install/j_update_js_css_path.php b/f_install/j_update_js_css_path.php index e561e3b9..8b4ebcd1 100644 --- a/f_install/j_update_js_css_path.php +++ b/f_install/j_update_js_css_path.php @@ -33,7 +33,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); @@ -64,7 +69,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); diff --git a/f_install/templates/ServConfig.orig.php b/f_install/templates/ServConfig.orig.php index ab3947aa..7ac66e3e 100644 --- a/f_install/templates/ServConfig.orig.php +++ b/f_install/templates/ServConfig.orig.php @@ -13,6 +13,9 @@ class ServConfig public static $gameImagePath = "_tK_gameImagePath_"; public static $imageRequestPath = "_tK_imageRequestPath_"; public static $imageRequestKey = '_tK_imageRequestKey_'; + public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_; + public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_'; + public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_'; private static $serverList = null; public static function getSharedIconPath(string $filepath = ''): string @@ -41,6 +44,32 @@ class ServConfig return static::$imageRequestPath; } + public static function isRemoteUserIconUploadEnabled(): bool + { + return static::$remoteUserIconUploadEnabled; + } + + public static function getRemoteUserIconUploadURI(string $filename): string + { + return rtrim(static::$remoteUserIconUploadPath, '/') . '/v1/uploads/user-icons/core/' . $filename; + } + + public static function getRemoteUserIconUploadSecret(): string + { + $path = static::$remoteUserIconUploadSecretFile; + if ($path === '' || str_contains($path, "\0")) { + throw new \RuntimeException('Remote user icon upload secret file is not configured'); + } + if ($path[0] !== '/') { + $path = ROOT . '/' . $path; + } + $secret = trim((string)file_get_contents($path)); + if (strlen($secret) < 32) { + throw new \RuntimeException('Remote user icon upload secret must be at least 32 characters'); + } + return $secret; + } + /** * 서버 설정 반환 * diff --git a/i_entrance/j_icon_change.php b/i_entrance/j_icon_change.php index afc1e2b1..3b77822d 100644 --- a/i_entrance/j_icon_change.php +++ b/i_entrance/j_icon_change.php @@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) { break; } - if(!move_uploaded_file($image['tmp_name'], $dest)) { + if (RemoteUserIconUploadClient::isConfiguredEnabled()) { + try { + $remoteName = bin2hex(random_bytes(16)).$newExt; + $contentType = image_type_to_mime_type($imageType); + RemoteUserIconUploadClient::uploadConfigured( + $remoteName, + $contentType, + (string)file_get_contents($image['tmp_name']) + ); + $newPicName = "users/core/{$remoteName}"; + $storedRemotely = true; + } catch (\Throwable $error) { + error_log('Remote user icon upload failed: ' . $error->getMessage()); + $storedRemotely = false; + } + } else { + $storedRemotely = null; + } + + if($storedRemotely === false) { + $response['reason'] = '원격 이미지 저장소 업로드에 실패했습니다!'; + $response['result'] = false; + } elseif($storedRemotely === null && !move_uploaded_file($image['tmp_name'], $dest)) { $response['reason'] = '업로드에 실패했습니다!'; $response['result'] = false; } else { $pic = "{$newPicName}?={$rf}"; RootDB::db()->update('member',[ 'PICTURE' => $pic, - 'IMGSVR' => 1 + 'IMGSVR' => $storedRemotely === true ? 0 : 1 ], 'NO=%i', $userID); $servers = []; @@ -104,4 +126,4 @@ if(!is_uploaded_file($image['tmp_name'])) { } -Json::die($response); \ No newline at end of file +Json::die($response); diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php new file mode 100644 index 00000000..cc4e3b02 --- /dev/null +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -0,0 +1,118 @@ + */ + public static function uploadConfigured(string $filename, string $contentType, string $body): array + { + if (!self::isConfiguredEnabled() + || !property_exists(ServConfig::class, 'remoteUserIconUploadPath') + || !property_exists(ServConfig::class, 'remoteUserIconUploadSecretFile')) { + throw new \RuntimeException('Remote user icon upload is not configured'); + } + $secretPath = ServConfig::$remoteUserIconUploadSecretFile; + if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) { + throw new \RuntimeException('Remote user icon upload secret file is not configured'); + } + if ($secretPath[0] !== '/') { + $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 array{headers:list,requestId:string,expires:string} */ + public static function buildRequest( + string $url, + string $client, + string $secret, + string $contentType, + string $body, + ?int $expires = null, + ?string $requestId = null + ): array { + if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) { + throw new \InvalidArgumentException('Invalid image upload client'); + } + if (strlen($secret) < 32) { + 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)) { + throw new \InvalidArgumentException('Invalid image upload URL'); + } + $expiresText = (string)($expires ?? time() + 60); + $requestId ??= bin2hex(random_bytes(16)); + $digest = hash('sha256', $body); + $signature = hash_hmac( + 'sha256', + "{$expiresText}.{$requestId}.{$path}.{$contentType}.{$digest}", + $secret + ); + return [ + 'requestId' => $requestId, + 'expires' => $expiresText, + 'headers' => [ + "Content-Type: {$contentType}", + "X-Image-Client: {$client}", + "X-Image-Expires: {$expiresText}", + "X-Image-Request-Id: {$requestId}", + "X-Image-Signature: {$signature}", + ], + ]; + } + + /** @return array */ + public static function upload(string $url, string $client, string $secret, string $contentType, string $body): array + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) { + throw new \InvalidArgumentException('Image upload URL must use HTTPS except for loopback tests'); + } + $request = self::buildRequest($url, $client, $secret, $contentType, $body); + $curl = curl_init($url); + if ($curl === false) { + throw new \RuntimeException('Unable to initialize image upload request'); + } + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_HTTPHEADER => $request['headers'], + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + ]); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($response === false) { + throw new \RuntimeException("Image upload request failed: {$error}"); + } + $decoded = Json::decode($response); + if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { + 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}") { + throw new \RuntimeException('Image upload returned an unexpected path'); + } + return $decoded; + } +} diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php new file mode 100644 index 00000000..271f2ad8 --- /dev/null +++ b/tests/RemoteUserIconUploadClientTest.php @@ -0,0 +1,45 @@ +expectException(InvalidArgumentException::class); + RemoteUserIconUploadClient::buildRequest( + 'https://sam-image.hided.net/v1/uploads/user-icons/core2026/' . str_repeat('a', 32) . '.png', + 'core', + str_repeat('u', 32), + 'image/png', + 'body' + ); + } +}