Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb931d3f0 | ||
|
|
b095fb4bf1 | ||
|
|
234ec919bc | ||
|
|
a953ab4154 | ||
|
|
56a023e195 | ||
|
|
69fb122ab1 | ||
|
|
f9b5733fb8 | ||
|
|
66e2e28df2 |
@@ -42,7 +42,32 @@ sudo -u www-data git clone https://storage.hided.net/gitea/devsam/core.git
|
||||
sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git
|
||||
```
|
||||
|
||||
> 이미지는 hook/git_hook.php을 통해 동기화되며, 서버 설치 과정에 이미지 갱신 키를 지정하는 것으로 '훼' 서버 업데이트 시 동기화됩니다. 이미지 서버가 게임 서버와 별개여도 동작하나, php와 git을 지원해야합니다.
|
||||
> 이미지는 Gitea webhook을 기본으로 동기화합니다. Webhook 전달이 누락된 경우에도 서버 설치 과정에서 이미지 갱신 API와 `core` 전용 비밀값을 지정하면 게임 서버 업데이트 명령이 서명된 동기화를 요청합니다. 이미지 서버의 브랜치 변경 권한은 부여되지 않습니다.
|
||||
|
||||
CLI에서 수동으로 복구 동기화를 요청할 수도 있습니다.
|
||||
|
||||
```sh
|
||||
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` 공유 이미지 경로를 저장합니다.
|
||||
국방·외교 등 TipTap 편집기 첨부 이미지도 같은 플래그로 `/uploads/core/` bind
|
||||
저장소로 전환됩니다. 공유 비밀값은 브라우저나 Cloudflare로 보내지 않습니다.
|
||||
|
||||
### 설치
|
||||
|
||||
@@ -67,4 +92,4 @@ Database 수는 로그인 관리 서버 1개, 내부 서버 7개로, 총 8개의
|
||||
* MIT License
|
||||
* GPL 2.0 또는 이후
|
||||
|
||||
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
|
||||
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
|
||||
|
||||
+11
-4
@@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
|
||||
<div class="form-group row">
|
||||
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/icons" />
|
||||
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/icons" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/game" />
|
||||
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/game" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="image_request_path" class="col-sm-4 col-form-label">이미지 갱신 API</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="url" class="form-control" name="image_request_path" id="image_request_path" value="https://sam-image.hided.net/v1/sync" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
|
||||
<div class="input-group col-sm-8">
|
||||
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 hook/HashKey.php의 값과 동일하게" value="" />
|
||||
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 core 동기화 비밀값과 동일하게" value="" />
|
||||
<div class="input-group-text">
|
||||
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
|
||||
</div>
|
||||
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name');
|
||||
$servHost = Util::getPost('serv_host');
|
||||
$sharedIconPath = Util::getPost('shared_icon_path');
|
||||
$gameImagePath = Util::getPost('game_image_path');
|
||||
$imageRequestPath = Util::getPost('image_request_path');
|
||||
$imageRequestKey = Util::getPost('image_request_key');
|
||||
|
||||
$kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', '');
|
||||
$kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', '');
|
||||
|
||||
|
||||
if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath) {
|
||||
if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath || !$imageRequestPath) {
|
||||
Json::die([
|
||||
'result' => false,
|
||||
'reason' => '입력 값이 올바르지 않습니다'
|
||||
@@ -32,6 +33,21 @@ if (!filter_var($servHost, FILTER_VALIDATE_URL)) {
|
||||
]);
|
||||
}
|
||||
|
||||
if (!filter_var($imageRequestPath, FILTER_VALIDATE_URL)
|
||||
|| parse_url($imageRequestPath, PHP_URL_SCHEME) !== 'https') {
|
||||
Json::die([
|
||||
'result' => false,
|
||||
'reason' => '이미지 갱신 API는 HTTPS URL이어야 합니다.'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($imageRequestKey !== null && $imageRequestKey !== '' && strlen($imageRequestKey) < 32) {
|
||||
Json::die([
|
||||
'result' => false,
|
||||
'reason' => '이미지 동기화 비밀값은 32자 이상이어야 합니다.'
|
||||
]);
|
||||
}
|
||||
|
||||
if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) {
|
||||
Json::die([
|
||||
'result' => false,
|
||||
@@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16));
|
||||
|
||||
$sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost);
|
||||
$gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost);
|
||||
$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost);
|
||||
$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost);
|
||||
$imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost);
|
||||
|
||||
$result = Util::generateFileUsingSimpleTemplate(
|
||||
__DIR__ . '/templates/ServConfig.orig.php',
|
||||
@@ -197,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'],
|
||||
@@ -209,10 +227,6 @@ $result = Util::generateFileUsingSimpleTemplate(
|
||||
true
|
||||
);
|
||||
|
||||
if ($imageRequestKey) {
|
||||
@file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey);
|
||||
}
|
||||
|
||||
if ($result !== true) {
|
||||
Json::die([
|
||||
'result' => false,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -38,9 +41,33 @@ class ServConfig
|
||||
|
||||
public static function getImagePullURI(): string
|
||||
{
|
||||
$now = time();
|
||||
$req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey);
|
||||
return static::$imageRequestPath . "?req={$req_hash}&time={$now}";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1734,7 +1734,7 @@ function deleteNation(General $lord, bool $applyDB): array
|
||||
|
||||
$nationGeneralList = General::createObjListFromDB(
|
||||
$db->queryFirstColumn(
|
||||
'SELECT `no` FROM general WHERE nation=%i AND no != %i',
|
||||
'SELECT `no` FROM general WHERE nation=%i AND no != %i ORDER BY no ASC',
|
||||
$nationID,
|
||||
$lordID
|
||||
),
|
||||
|
||||
@@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){
|
||||
|
||||
$db = DB::db();
|
||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||
$clock = GameClock::fromStorage($gameStor);
|
||||
|
||||
if(file_exists(__DIR__.'/.htaccess')){
|
||||
$reserved = $db->queryFirstRow(
|
||||
@@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){
|
||||
|
||||
//TODO: 천통시에도 예약 오픈 알림이 필요..?
|
||||
|
||||
$usesLogicalClock = GameClock::isInitialized($gameStor);
|
||||
$admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']);
|
||||
$admin['maxUserCnt'] = $admin['maxgeneral'];
|
||||
$admin['npcMode'] = $admin['npcmode'];
|
||||
$admin['turnTerm'] = $admin['turnterm'];
|
||||
$admin['isUnited'] = $admin['isunited'];
|
||||
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
|
||||
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
|
||||
$admin['starttime'] = substr($admin['opentime'], 5, 11);
|
||||
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
|
||||
if($usesLogicalClock){
|
||||
$clock = GameClock::fromStorage($gameStor);
|
||||
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
|
||||
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
|
||||
$admin['starttime'] = substr($admin['opentime'], 5, 11);
|
||||
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
|
||||
}
|
||||
else{
|
||||
$admin['isOpen'] = new \DateTimeImmutable((string)$admin['opentime']) <= GameClock::readWallTime();
|
||||
$admin['starttime'] = substr((string)$admin['opentime'], 5, 11);
|
||||
$admin['turntime'] = substr((string)$admin['turntime'], 5, 11);
|
||||
}
|
||||
unset($admin['npcmode']);
|
||||
unset($admin['maxgeneral']);
|
||||
unset($admin['turnterm']);
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
|
||||
|
||||
$city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
|
||||
|
||||
$defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0', $city->getVar('nation'), $city->getVar('city'));
|
||||
$defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0 ORDER BY no', $city->getVar('nation'), $city->getVar('city'));
|
||||
$defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null);
|
||||
|
||||
/** @var WarUnit[] */
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
|
||||
$month,
|
||||
)));
|
||||
|
||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s', GameConst::$defaultSpecialDomestic) as $general) {
|
||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s ORDER BY no ASC', GameConst::$defaultSpecialDomestic) as $general) {
|
||||
$generalID = $general['no'];
|
||||
$special = SpecialityHelper::pickSpecialDomestic(
|
||||
$rng,
|
||||
@@ -57,7 +57,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
|
||||
$logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득");
|
||||
}
|
||||
|
||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s', GameConst::$defaultSpecialWar) as $general) {
|
||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s ORDER BY no ASC', GameConst::$defaultSpecialWar) as $general) {
|
||||
$generalID = $general['no'];
|
||||
$generalAux = Json::decode($general['aux']);
|
||||
|
||||
|
||||
@@ -69,11 +69,8 @@ function setupDBForm() {
|
||||
|
||||
$('#btn_random_generate_key').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
let token = '';
|
||||
while (token.length < 24) {
|
||||
token += (Math.random() + 1).toString(36).substring(7);
|
||||
}
|
||||
token = token.substr(0, 24);
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
$('#image_request_key').val(token);
|
||||
});
|
||||
|
||||
@@ -86,6 +83,7 @@ function setupDBForm() {
|
||||
serv_host: string,
|
||||
shared_icon_path: string,
|
||||
game_image_path: string,
|
||||
image_request_path: string,
|
||||
image_request_key: string,
|
||||
kakao_rest_key: string,
|
||||
kakao_admin_key: string,
|
||||
@@ -124,10 +122,14 @@ function setupDBForm() {
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
image_request_path: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
image_request_key: {
|
||||
required: false,
|
||||
type: 'string',
|
||||
min: 16,
|
||||
min: 32,
|
||||
},
|
||||
kakao_rest_key: {
|
||||
required: false,
|
||||
@@ -163,6 +165,7 @@ function setupDBForm() {
|
||||
serv_host: values.serv_host,
|
||||
shared_icon_path: values.shared_icon_path,
|
||||
game_image_path: values.game_image_path,
|
||||
image_request_path: values.image_request_path,
|
||||
image_request_key: values.image_request_key,
|
||||
kakao_rest_key: values.kakao_rest_key,
|
||||
kakao_admin_key: values.kakao_admin_key,
|
||||
@@ -313,4 +316,4 @@ $(function () {
|
||||
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
Json::die($response);
|
||||
|
||||
+12
-14
@@ -335,20 +335,18 @@ if ($server == $baseServerName) {
|
||||
|
||||
if (ServConfig::$imageRequestKey) {
|
||||
try {
|
||||
$imagePullPath = ServConfig::getImagePullURI();
|
||||
$pullResult = @file_get_contents($imagePullPath);
|
||||
if ($pullResult === false) {
|
||||
throw new \ErrorException('Invalid URI');
|
||||
}
|
||||
$pullResult = Json::decode($pullResult);
|
||||
if ($pullResult['result']) {
|
||||
$imgResult = true;
|
||||
$imgDetail = $pullResult['version'];
|
||||
} else {
|
||||
$imgResult = false;
|
||||
$imgDetail = $pullResult['reason'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$configuredPath = ServConfig::$imageRequestPath;
|
||||
$legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php');
|
||||
$imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL')
|
||||
?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath);
|
||||
$pullResult = ImageSyncClient::sync(
|
||||
$imageSyncPath,
|
||||
'core',
|
||||
ServConfig::$imageRequestKey
|
||||
);
|
||||
$imgResult = true;
|
||||
$imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current');
|
||||
} catch (\Throwable $e) {
|
||||
$imgResult = false;
|
||||
$imgDetail = $e->getMessage();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace sammo;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$url = getenv('IMAGE_SYNC_URL') ?: 'https://sam-image.hided.net/v1/sync';
|
||||
$secretFile = getenv('IMAGE_SYNC_SECRET_FILE') ?: '/run/secrets/image_sync_core_secret';
|
||||
if (!is_file($secretFile)) {
|
||||
fwrite(STDERR, "IMAGE_SYNC_SECRET_FILE is not readable.\n");
|
||||
exit(2);
|
||||
}
|
||||
$secret = trim(file_get_contents($secretFile));
|
||||
$result = ImageSyncClient::sync($url, 'core', $secret, $argv[1] ?? null);
|
||||
fwrite(STDOUT, Json::encode($result) . PHP_EOL);
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace sammo;
|
||||
|
||||
final class ImageSyncClient
|
||||
{
|
||||
/**
|
||||
* @return array{body:string,headers:list<string>,requestId:string}
|
||||
*/
|
||||
public static function buildRequest(
|
||||
string $client,
|
||||
string $secret,
|
||||
?string $commit = null,
|
||||
?int $timestampMs = null,
|
||||
?string $requestId = null
|
||||
): array {
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) {
|
||||
throw new \InvalidArgumentException('Invalid image sync client');
|
||||
}
|
||||
if (strlen($secret) < 32) {
|
||||
throw new \InvalidArgumentException('Image sync secret must be at least 32 characters');
|
||||
}
|
||||
if ($commit !== null && !preg_match('/^[0-9a-f]{40,64}$/i', $commit)) {
|
||||
throw new \InvalidArgumentException('Image commit must be a full Git SHA');
|
||||
}
|
||||
$body = Json::encode($commit === null ? (object)[] : ['commit' => $commit]);
|
||||
$timestamp = (string)($timestampMs ?? (int)floor(microtime(true) * 1000));
|
||||
$requestId ??= bin2hex(random_bytes(16));
|
||||
$signature = hash_hmac('sha256', "{$timestamp}.{$requestId}.{$body}", $secret);
|
||||
return [
|
||||
'body' => $body,
|
||||
'requestId' => $requestId,
|
||||
'headers' => [
|
||||
'Content-Type: application/json',
|
||||
"X-Image-Client: {$client}",
|
||||
"X-Image-Timestamp: {$timestamp}",
|
||||
"X-Image-Request-Id: {$requestId}",
|
||||
"X-Image-Signature: {$signature}",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function sync(string $url, string $client, string $secret, ?string $commit = null): 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 sync URL must use HTTPS except for loopback tests');
|
||||
}
|
||||
$request = self::buildRequest($client, $secret, $commit);
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new \RuntimeException('Unable to initialize image sync request');
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $request['headers'],
|
||||
CURLOPT_POSTFIELDS => $request['body'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$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 sync request failed: {$error}");
|
||||
}
|
||||
$decoded = Json::decode($response);
|
||||
if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) {
|
||||
throw new \RuntimeException("Image sync rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error'));
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?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
|
||||
{
|
||||
[$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')
|
||||
|| !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 [rtrim((string)ServConfig::$remoteUserIconUploadPath, '/'), $secret];
|
||||
}
|
||||
|
||||
/** @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|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);
|
||||
$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));
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -146,14 +146,41 @@ final class GameClockBoundaryTest extends TestCase
|
||||
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue'));
|
||||
}
|
||||
|
||||
public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void
|
||||
public function testGatewayReadsClockOnlyAfterClosedReservationResponse(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
|
||||
self::assertIsString($source);
|
||||
|
||||
$closedBranch = strpos($source, "if(file_exists(__DIR__.'/.htaccess'))");
|
||||
$closedBranchEnd = strpos($source, '//TODO: 천통시에도 예약 오픈 알림이 필요..?');
|
||||
$clockDetection = strpos($source, 'GameClock::isInitialized($gameStor)');
|
||||
$clockRead = strpos($source, 'GameClock::fromStorage($gameStor)');
|
||||
|
||||
self::assertNotFalse($closedBranch);
|
||||
self::assertNotFalse($closedBranchEnd);
|
||||
self::assertNotFalse($clockDetection);
|
||||
self::assertNotFalse($clockRead);
|
||||
self::assertGreaterThan($closedBranch, $closedBranchEnd);
|
||||
self::assertGreaterThan($closedBranchEnd, $clockDetection);
|
||||
self::assertGreaterThan($closedBranchEnd, $clockRead);
|
||||
}
|
||||
|
||||
public function testGatewayPreservesWallClockProfilesAndFormatsLogicalOpenTime(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
|
||||
self::assertIsString($source);
|
||||
self::assertStringContainsString(
|
||||
'$usesLogicalClock = GameClock::isInitialized($gameStor);',
|
||||
$source,
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
|
||||
$source,
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
'$admin[\'isOpen\'] = new \\DateTimeImmutable((string)$admin[\'opentime\']) <= GameClock::readWallTime();',
|
||||
$source,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -109,6 +109,10 @@ final class GameClockTest extends TestCase
|
||||
]);
|
||||
self::assertFalse(GameClock::isInitialized($legacyStorage));
|
||||
|
||||
$reservedResetStorage = $this->createMock(KVStorage::class);
|
||||
$reservedResetStorage->method('getValues')->willReturn([]);
|
||||
self::assertFalse(GameClock::isInitialized($reservedResetStorage));
|
||||
|
||||
$partialStorage = $this->createMock(KVStorage::class);
|
||||
$partialStorage->method('getValues')->willReturn([
|
||||
'clock_tick' => 0,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use sammo\ImageSyncClient;
|
||||
|
||||
require_once dirname(__DIR__) . '/src/sammo/ImageSyncClient.php';
|
||||
|
||||
final class ImageSyncClientTest extends TestCase
|
||||
{
|
||||
public function testBuildRequestSignsTheExactBody(): void
|
||||
{
|
||||
$secret = str_repeat('c', 32);
|
||||
$request = ImageSyncClient::buildRequest(
|
||||
'core',
|
||||
$secret,
|
||||
str_repeat('a', 40),
|
||||
1786013000000,
|
||||
'core-request-1234'
|
||||
);
|
||||
$headers = implode("\n", $request['headers']);
|
||||
$expected = hash_hmac(
|
||||
'sha256',
|
||||
"1786013000000.core-request-1234.{$request['body']}",
|
||||
$secret
|
||||
);
|
||||
self::assertStringContainsString("X-Image-Signature: {$expected}", $headers);
|
||||
self::assertSame('{"commit":"' . str_repeat('a', 40) . '"}', $request['body']);
|
||||
}
|
||||
|
||||
public function testBuildRequestRejectsAbbreviatedCommit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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'
|
||||
);
|
||||
}
|
||||
|
||||
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