feat: add flagged remote user icon upload
This commit is contained in:
@@ -51,6 +51,23 @@ IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \
|
|||||||
php scripts/sync-image-repository.php
|
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로 보내지 않습니다.
|
||||||
|
|
||||||
### 설치
|
### 설치
|
||||||
|
|
||||||
이후 해당 경로를 웹 브라우저를 통해 접근하여 설치를 진행할 수 있습니다.
|
이후 해당 경로를 웹 브라우저를 통해 접근하여 설치를 진행할 수 있습니다.
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ $result = Util::generateFileUsingSimpleTemplate(
|
|||||||
'gameImagePath' => $gameImagePath,
|
'gameImagePath' => $gameImagePath,
|
||||||
'imageRequestPath' => $imageRequestPath,
|
'imageRequestPath' => $imageRequestPath,
|
||||||
'imageRequestKey' => $imageRequestKey,
|
'imageRequestKey' => $imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => 'false',
|
||||||
|
'remoteUserIconUploadPath' => 'https://sam-image.hided.net',
|
||||||
|
'remoteUserIconUploadSecretFile' => 'd_setting/image_upload_core_secret',
|
||||||
'serverList' => [
|
'serverList' => [
|
||||||
['che', '체', 'white'],
|
['che', '체', 'white'],
|
||||||
['kwe', '퀘', 'yellow'],
|
['kwe', '퀘', 'yellow'],
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ if ($servHost) {
|
|||||||
[
|
[
|
||||||
'serverBasePath' => $servHost,
|
'serverBasePath' => $servHost,
|
||||||
'sharedIconPath' => $sharedIconPath,
|
'sharedIconPath' => $sharedIconPath,
|
||||||
'gameImagePath' => $gameImagePath
|
'gameImagePath' => $gameImagePath,
|
||||||
|
'imageRequestPath' => ServConfig::$imageRequestPath,
|
||||||
|
'imageRequestKey' => ServConfig::$imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
|
||||||
|
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
|
||||||
|
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
|
||||||
],
|
],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -64,7 +69,12 @@ if ($servHost) {
|
|||||||
[
|
[
|
||||||
'serverBasePath' => $servHost,
|
'serverBasePath' => $servHost,
|
||||||
'sharedIconPath' => $sharedIconPath,
|
'sharedIconPath' => $sharedIconPath,
|
||||||
'gameImagePath' => $gameImagePath
|
'gameImagePath' => $gameImagePath,
|
||||||
|
'imageRequestPath' => ServConfig::$imageRequestPath,
|
||||||
|
'imageRequestKey' => ServConfig::$imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
|
||||||
|
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
|
||||||
|
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
|
||||||
],
|
],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class ServConfig
|
|||||||
public static $gameImagePath = "_tK_gameImagePath_";
|
public static $gameImagePath = "_tK_gameImagePath_";
|
||||||
public static $imageRequestPath = "_tK_imageRequestPath_";
|
public static $imageRequestPath = "_tK_imageRequestPath_";
|
||||||
public static $imageRequestKey = '_tK_imageRequestKey_';
|
public static $imageRequestKey = '_tK_imageRequestKey_';
|
||||||
|
public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_;
|
||||||
|
public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_';
|
||||||
|
public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_';
|
||||||
private static $serverList = null;
|
private static $serverList = null;
|
||||||
|
|
||||||
public static function getSharedIconPath(string $filepath = ''): string
|
public static function getSharedIconPath(string $filepath = ''): string
|
||||||
@@ -41,6 +44,32 @@ class ServConfig
|
|||||||
return static::$imageRequestPath;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서버 설정 반환
|
* 서버 설정 반환
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) {
|
|||||||
break;
|
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['reason'] = '업로드에 실패했습니다!';
|
||||||
$response['result'] = false;
|
$response['result'] = false;
|
||||||
} else {
|
} else {
|
||||||
$pic = "{$newPicName}?={$rf}";
|
$pic = "{$newPicName}?={$rf}";
|
||||||
RootDB::db()->update('member',[
|
RootDB::db()->update('member',[
|
||||||
'PICTURE' => $pic,
|
'PICTURE' => $pic,
|
||||||
'IMGSVR' => 1
|
'IMGSVR' => $storedRemotely === true ? 0 : 1
|
||||||
], 'NO=%i', $userID);
|
], 'NO=%i', $userID);
|
||||||
|
|
||||||
$servers = [];
|
$servers = [];
|
||||||
@@ -104,4 +126,4 @@ if(!is_uploaded_file($image['tmp_name'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Json::die($response);
|
Json::die($response);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace sammo;
|
||||||
|
|
||||||
|
final class RemoteUserIconUploadClient
|
||||||
|
{
|
||||||
|
public static function isConfiguredEnabled(): bool
|
||||||
|
{
|
||||||
|
return property_exists(ServConfig::class, 'remoteUserIconUploadEnabled')
|
||||||
|
&& ServConfig::$remoteUserIconUploadEnabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
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<string>,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<string,mixed> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use sammo\RemoteUserIconUploadClient;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/src/sammo/RemoteUserIconUploadClient.php';
|
||||||
|
|
||||||
|
final class RemoteUserIconUploadClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBuildRequestBindsExpiryPathContentTypeAndBody(): void
|
||||||
|
{
|
||||||
|
$secret = str_repeat('u', 32);
|
||||||
|
$body = "\x89PNG\r\n\x1a\nbody";
|
||||||
|
$url = 'https://sam-image.hided.net/v1/uploads/user-icons/core/' . str_repeat('a', 32) . '.png';
|
||||||
|
$request = RemoteUserIconUploadClient::buildRequest(
|
||||||
|
$url,
|
||||||
|
'core',
|
||||||
|
$secret,
|
||||||
|
'image/png',
|
||||||
|
$body,
|
||||||
|
1786012860,
|
||||||
|
'core-upload-1234'
|
||||||
|
);
|
||||||
|
$expected = hash_hmac(
|
||||||
|
'sha256',
|
||||||
|
'1786012860.core-upload-1234./v1/uploads/user-icons/core/' . str_repeat('a', 32)
|
||||||
|
. '.png.image/png.' . hash('sha256', $body),
|
||||||
|
$secret
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("X-Image-Signature: {$expected}", implode("\n", $request['headers']));
|
||||||
|
self::assertStringNotContainsString($secret, implode("\n", $request['headers']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildRequestRejectsAPathOutsideTheCallerScope(): void
|
||||||
|
{
|
||||||
|
$this->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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user