Compare commits

...
14 Commits
27 changed files with 1280 additions and 90 deletions
+27 -2
View File
@@ -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 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 * MIT License
* GPL 2.0 또는 이후 * GPL 2.0 또는 이후
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. 만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
+11 -4
View File
@@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
<div class="form-group row"> <div class="form-group row">
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label> <label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
<div class="col-sm-8"> <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> </div>
<div class="form-group row"> <div class="form-group row">
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label> <label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
<div class="col-sm-8"> <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> </div>
<div class="form-group row"> <div class="form-group row">
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label> <label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
<div class="input-group col-sm-8"> <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"> <div class="input-group-text">
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button> <button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
</div> </div>
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
</div> </div>
</body> </body>
</html> </html>
+21 -7
View File
@@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name');
$servHost = Util::getPost('serv_host'); $servHost = Util::getPost('serv_host');
$sharedIconPath = Util::getPost('shared_icon_path'); $sharedIconPath = Util::getPost('shared_icon_path');
$gameImagePath = Util::getPost('game_image_path'); $gameImagePath = Util::getPost('game_image_path');
$imageRequestPath = Util::getPost('image_request_path');
$imageRequestKey = Util::getPost('image_request_key'); $imageRequestKey = Util::getPost('image_request_key');
$kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', ''); $kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', '');
$kakaoAdminKey = Util::getPost('kakao_admin_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([ Json::die([
'result' => false, 'result' => false,
'reason' => '입력 값이 올바르지 않습니다' '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')) { if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) {
Json::die([ Json::die([
'result' => false, 'result' => false,
@@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16));
$sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost); $sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost);
$gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost); $gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost);
$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost); $imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost);
$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost);
$result = Util::generateFileUsingSimpleTemplate( $result = Util::generateFileUsingSimpleTemplate(
__DIR__ . '/templates/ServConfig.orig.php', __DIR__ . '/templates/ServConfig.orig.php',
@@ -197,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'],
@@ -209,10 +227,6 @@ $result = Util::generateFileUsingSimpleTemplate(
true true
); );
if ($imageRequestKey) {
@file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey);
}
if ($result !== true) { if ($result !== true) {
Json::die([ Json::die([
'result' => false, 'result' => false,
+12 -2
View File
@@ -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
); );
+30 -3
View File
@@ -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
@@ -38,9 +41,33 @@ class ServConfig
public static function getImagePullURI(): string public static function getImagePullURI(): string
{ {
$now = time(); return static::$imageRequestPath;
$req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey); }
return static::$imageRequestPath . "?req={$req_hash}&time={$now}";
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
View File
@@ -1734,7 +1734,7 @@ function deleteNation(General $lord, bool $applyDB): array
$nationGeneralList = General::createObjListFromDB( $nationGeneralList = General::createObjListFromDB(
$db->queryFirstColumn( $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, $nationID,
$lordID $lordID
), ),
+9 -1
View File
@@ -780,7 +780,7 @@ function checkEmperior()
$chiefs = Util::convertArrayToDict( $chiefs = Util::convertArrayToDict(
$db->query( $db->query(
'SELECT no,npc,name,picture,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5', 'SELECT no,npc,name,picture,imgsvr,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
$nationID $nationID
), ),
'officer_level' 'officer_level'
@@ -893,20 +893,28 @@ function checkEmperior()
'rice' => $nation['rice'], 'rice' => $nation['rice'],
'l12name' => $chiefs[12]['name'], 'l12name' => $chiefs[12]['name'],
'l12pic' => $chiefs[12]['picture'], 'l12pic' => $chiefs[12]['picture'],
'l12imgsvr' => $chiefs[12]['imgsvr'],
'l11name' => $chiefs[11]['name'], 'l11name' => $chiefs[11]['name'],
'l11pic' => $chiefs[11]['picture'], 'l11pic' => $chiefs[11]['picture'],
'l11imgsvr' => $chiefs[11]['imgsvr'],
'l10name' => $chiefs[10]['name'], 'l10name' => $chiefs[10]['name'],
'l10pic' => $chiefs[10]['picture'], 'l10pic' => $chiefs[10]['picture'],
'l10imgsvr' => $chiefs[10]['imgsvr'],
'l9name' => $chiefs[9]['name'], 'l9name' => $chiefs[9]['name'],
'l9pic' => $chiefs[9]['picture'], 'l9pic' => $chiefs[9]['picture'],
'l9imgsvr' => $chiefs[9]['imgsvr'],
'l8name' => $chiefs[8]['name'], 'l8name' => $chiefs[8]['name'],
'l8pic' => $chiefs[8]['picture'], 'l8pic' => $chiefs[8]['picture'],
'l8imgsvr' => $chiefs[8]['imgsvr'],
'l7name' => $chiefs[7]['name'], 'l7name' => $chiefs[7]['name'],
'l7pic' => $chiefs[7]['picture'], 'l7pic' => $chiefs[7]['picture'],
'l7imgsvr' => $chiefs[7]['imgsvr'],
'l6name' => $chiefs[6]['name'], 'l6name' => $chiefs[6]['name'],
'l6pic' => $chiefs[6]['picture'], 'l6pic' => $chiefs[6]['picture'],
'l6imgsvr' => $chiefs[6]['imgsvr'],
'l5name' => $chiefs[5]['name'], 'l5name' => $chiefs[5]['name'],
'l5pic' => $chiefs[5]['picture'], 'l5pic' => $chiefs[5]['picture'],
'l5imgsvr' => $chiefs[5]['imgsvr'],
'tiger' => $tigerstr, 'tiger' => $tigerstr,
'eagle' => $eaglestr, 'eagle' => $eaglestr,
'gen' => $gen, 'gen' => $gen,
+13 -5
View File
@@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStor);
if(file_exists(__DIR__.'/.htaccess')){ if(file_exists(__DIR__.'/.htaccess')){
$reserved = $db->queryFirstRow( $reserved = $db->queryFirstRow(
@@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){
//TODO: 천통시에도 예약 오픈 알림이 필요..? //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 = $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['maxUserCnt'] = $admin['maxgeneral'];
$admin['npcMode'] = $admin['npcmode']; $admin['npcMode'] = $admin['npcmode'];
$admin['turnTerm'] = $admin['turnterm']; $admin['turnTerm'] = $admin['turnterm'];
$admin['isUnited'] = $admin['isunited']; $admin['isUnited'] = $admin['isunited'];
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); if($usesLogicalClock){
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime'])); $clock = GameClock::fromStorage($gameStor);
$admin['starttime'] = substr($admin['opentime'], 5, 11); $admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); $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['npcmode']);
unset($admin['maxgeneral']); unset($admin['maxgeneral']);
unset($admin['turnterm']); unset($admin['turnterm']);
+1 -1
View File
@@ -37,7 +37,7 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
$city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear); $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); $defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null);
/** @var WarUnit[] */ /** @var WarUnit[] */
+27 -15
View File
@@ -8,6 +8,7 @@ use sammo\AppConf;
use sammo\Enums\APIRecoveryType; use sammo\Enums\APIRecoveryType;
use sammo\KVStorage; use sammo\KVStorage;
use sammo\RootDB; use sammo\RootDB;
use sammo\RemoteUserIconUploadClient;
use sammo\TimeUtil; use sammo\TimeUtil;
use sammo\UniqueConst; use sammo\UniqueConst;
use sammo\Validator; use sammo\Validator;
@@ -60,22 +61,33 @@ class UploadImage extends \sammo\BaseAPI
$imgName = hash_final($oMD); $imgName = hash_final($oMD);
$imgFullName = "{$imgName}.{$extension}"; $imgFullName = "{$imgName}.{$extension}";
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image'; $remotePath = null;
$destPath = "{$destDir}/{$imgFullName}"; if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
try {
RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData);
$remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName);
} catch (\Throwable $error) {
RemoteUserIconUploadClient::logFailure('content-image', $error);
return '원격 이미지 저장소 업로드에 실패했습니다!';
}
} else {
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
$destPath = "{$destDir}/{$imgFullName}";
if (!file_exists($destPath)) { if (!file_exists($destPath)) {
if (!file_exists($destDir)) { if (!file_exists($destDir)) {
mkdir($destDir); mkdir($destDir);
} }
if (!is_dir($destDir)) { if (!is_dir($destDir)) {
return '버그! 업로드 경로 확인!'; return '버그! 업로드 경로 확인!';
} }
if (!is_writable($destDir)) { if (!is_writable($destDir)) {
return '버그! 업로드 권한 확인!'; return '버그! 업로드 권한 확인!';
} }
if (!file_put_contents($destPath, $imageData)) { if (!file_put_contents($destPath, $imageData)) {
return '업로드에 실패했습니다!'; return '업로드에 실패했습니다!';
}
} }
} }
@@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI
return [ return [
'result' => true, 'result' => true,
'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName, 'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
]; ];
} }
} }
+8 -4
View File
@@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env'); $gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
$env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']); $env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']);
$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;
$formatStoredTime = static fn (mixed $value): string => $clock === null
? (string)$value
: $clock->formatTick(Util::toInt($value));
$env['turntime'] = $formatStoredTime($env['turntime']);
$me = $db->queryFirstRow( $me = $db->queryFirstRow(
'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general` 'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general`
@@ -187,7 +192,7 @@ class GeneralList extends \sammo\BaseAPI
if (!key_exists($troopLeaderID, $rawGeneralList)) { if (!key_exists($troopLeaderID, $rawGeneralList)) {
continue; continue;
} }
$troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime']; $troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']);
$troops[$troopLeaderID] = new ArrayObject([ $troops[$troopLeaderID] = new ArrayObject([
'id' => $troopLeaderID, 'id' => $troopLeaderID,
'name' => $troopName, 'name' => $troopName,
@@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']), 'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']), 'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
//'0000-00-00 11:23'; //'0000-00-00 11:23';
'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor) 'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19),
->formatTick(Util::toInt($rawGeneral['turntime'])),
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null 'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
? null ? null
: GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])), : substr($formatStoredTime($rawGeneral['recent_war']), 0, 19),
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']), 'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null, 'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0, 'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
@@ -37,7 +37,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
$month, $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']; $generalID = $general['no'];
$special = SpecialityHelper::pickSpecialDomestic( $special = SpecialityHelper::pickSpecialDomestic(
$rng, $rng,
@@ -57,7 +57,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
$logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득"); $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']; $generalID = $general['no'];
$generalAux = Json::decode($general['aux']); $generalAux = Json::decode($general['aux']);
+17 -9
View File
@@ -11,7 +11,7 @@ CREATE TABLE `general` (
`bornyear` INT(3) NULL DEFAULT '180', `bornyear` INT(3) NULL DEFAULT '180',
`deadyear` INT(3) NULL DEFAULT '300', `deadyear` INT(3) NULL DEFAULT '300',
`newmsg` INT(1) NULL DEFAULT '0', `newmsg` INT(1) NULL DEFAULT '0',
`picture` VARCHAR(40) NOT NULL, `picture` VARCHAR(64) NOT NULL,
`imgsvr` INT(1) NOT NULL DEFAULT '0', `imgsvr` INT(1) NOT NULL DEFAULT '0',
`name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin', `name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin',
`owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin', `owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin',
@@ -346,21 +346,29 @@ CREATE TABLE IF NOT EXISTS `emperior` (
`gold` INT(9) NULL DEFAULT '0', `gold` INT(9) NULL DEFAULT '0',
`rice` INT(9) NULL DEFAULT '0', `rice` INT(9) NULL DEFAULT '0',
`l12name` VARCHAR(64) NULL DEFAULT '', `l12name` VARCHAR(64) NULL DEFAULT '',
`l12pic` VARCHAR(32) NULL DEFAULT '', `l12pic` VARCHAR(64) NULL DEFAULT '',
`l12imgsvr` INT(1) NULL DEFAULT NULL,
`l11name` VARCHAR(64) NULL DEFAULT '', `l11name` VARCHAR(64) NULL DEFAULT '',
`l11pic` VARCHAR(32) NULL DEFAULT '', `l11pic` VARCHAR(64) NULL DEFAULT '',
`l11imgsvr` INT(1) NULL DEFAULT NULL,
`l10name` VARCHAR(64) NULL DEFAULT '', `l10name` VARCHAR(64) NULL DEFAULT '',
`l10pic` VARCHAR(32) NULL DEFAULT '', `l10pic` VARCHAR(64) NULL DEFAULT '',
`l10imgsvr` INT(1) NULL DEFAULT NULL,
`l9name` VARCHAR(64) NULL DEFAULT '', `l9name` VARCHAR(64) NULL DEFAULT '',
`l9pic` VARCHAR(32) NULL DEFAULT '', `l9pic` VARCHAR(64) NULL DEFAULT '',
`l9imgsvr` INT(1) NULL DEFAULT NULL,
`l8name` VARCHAR(64) NULL DEFAULT '', `l8name` VARCHAR(64) NULL DEFAULT '',
`l8pic` VARCHAR(32) NULL DEFAULT '', `l8pic` VARCHAR(64) NULL DEFAULT '',
`l8imgsvr` INT(1) NULL DEFAULT NULL,
`l7name` VARCHAR(64) NULL DEFAULT '', `l7name` VARCHAR(64) NULL DEFAULT '',
`l7pic` VARCHAR(32) NULL DEFAULT '', `l7pic` VARCHAR(64) NULL DEFAULT '',
`l7imgsvr` INT(1) NULL DEFAULT NULL,
`l6name` VARCHAR(64) NULL DEFAULT '', `l6name` VARCHAR(64) NULL DEFAULT '',
`l6pic` VARCHAR(32) NULL DEFAULT '', `l6pic` VARCHAR(64) NULL DEFAULT '',
`l6imgsvr` INT(1) NULL DEFAULT NULL,
`l5name` VARCHAR(64) NULL DEFAULT '', `l5name` VARCHAR(64) NULL DEFAULT '',
`l5pic` VARCHAR(32) NULL DEFAULT '', `l5pic` VARCHAR(64) NULL DEFAULT '',
`l5imgsvr` INT(1) NULL DEFAULT NULL,
`tiger` VARCHAR(128) NULL DEFAULT '', `tiger` VARCHAR(128) NULL DEFAULT '',
`eagle` VARCHAR(128) NULL DEFAULT '', `eagle` VARCHAR(128) NULL DEFAULT '',
`gen` TEXT NULL DEFAULT '', `gen` TEXT NULL DEFAULT '',
+10 -7
View File
@@ -69,11 +69,8 @@ function setupDBForm() {
$('#btn_random_generate_key').on('click', function (e) { $('#btn_random_generate_key').on('click', function (e) {
e.preventDefault(); e.preventDefault();
let token = ''; const bytes = crypto.getRandomValues(new Uint8Array(32));
while (token.length < 24) { const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
token += (Math.random() + 1).toString(36).substring(7);
}
token = token.substr(0, 24);
$('#image_request_key').val(token); $('#image_request_key').val(token);
}); });
@@ -86,6 +83,7 @@ function setupDBForm() {
serv_host: string, serv_host: string,
shared_icon_path: string, shared_icon_path: string,
game_image_path: string, game_image_path: string,
image_request_path: string,
image_request_key: string, image_request_key: string,
kakao_rest_key: string, kakao_rest_key: string,
kakao_admin_key: string, kakao_admin_key: string,
@@ -124,10 +122,14 @@ function setupDBForm() {
required: true, required: true,
type: 'string', type: 'string',
}, },
image_request_path: {
required: true,
type: 'string',
},
image_request_key: { image_request_key: {
required: false, required: false,
type: 'string', type: 'string',
min: 16, min: 32,
}, },
kakao_rest_key: { kakao_rest_key: {
required: false, required: false,
@@ -163,6 +165,7 @@ function setupDBForm() {
serv_host: values.serv_host, serv_host: values.serv_host,
shared_icon_path: values.shared_icon_path, shared_icon_path: values.shared_icon_path,
game_image_path: values.game_image_path, game_image_path: values.game_image_path,
image_request_path: values.image_request_path,
image_request_key: values.image_request_key, image_request_key: values.image_request_key,
kakao_rest_key: values.kakao_rest_key, kakao_rest_key: values.kakao_rest_key,
kakao_admin_key: values.kakao_admin_key, kakao_admin_key: values.kakao_admin_key,
@@ -313,4 +316,4 @@ $(function () {
}); });
+25 -3
View File
@@ -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) {
RemoteUserIconUploadClient::logFailure('user-icon', $error);
$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);
+12 -14
View File
@@ -335,20 +335,18 @@ if ($server == $baseServerName) {
if (ServConfig::$imageRequestKey) { if (ServConfig::$imageRequestKey) {
try { try {
$imagePullPath = ServConfig::getImagePullURI(); $configuredPath = ServConfig::$imageRequestPath;
$pullResult = @file_get_contents($imagePullPath); $legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php');
if ($pullResult === false) { $imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL')
throw new \ErrorException('Invalid URI'); ?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath);
} $pullResult = ImageSyncClient::sync(
$pullResult = Json::decode($pullResult); $imageSyncPath,
if ($pullResult['result']) { 'core',
$imgResult = true; ServConfig::$imageRequestKey
$imgDetail = $pullResult['version']; );
} else { $imgResult = true;
$imgResult = false; $imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current');
$imgDetail = $pullResult['reason']; } catch (\Throwable $e) {
}
} catch (\Exception $e) {
$imgResult = false; $imgResult = false;
$imgDetail = $e->getMessage(); $imgDetail = $e->getMessage();
} }
+6
View File
@@ -0,0 +1,6 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use sammo\DB;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']);
if (isset($options['help'])) {
pictureMigrationUsage();
}
if (isset($options['status']) === isset($options['apply'])) {
pictureMigrationUsage(2);
}
$server = $options['server'] ?? null;
if (!is_string($server) || preg_match('/^[a-z][a-z0-9_-]*$/', $server) !== 1) {
fwrite(STDERR, "--server must name one game-server directory, for example che, kwe, or hwe.\n");
exit(2);
}
$projectRoot = dirname(__DIR__);
$serverDirectory = $projectRoot . '/' . $server;
foreach (['lib.php', 'func.php', 'd_setting/DB.php'] as $requiredFile) {
if (!is_file($serverDirectory . '/' . $requiredFile)) {
fwrite(STDERR, "Server directory '$server' is not a configured game server: missing $requiredFile.\n");
exit(2);
}
}
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
$_SERVER['REQUEST_URI'] ??= "/cli/migrate-general-picture/$server";
require $serverDirectory . '/lib.php';
require $serverDirectory . '/func.php';
/** @return never */
function pictureMigrationUsage(int $exitCode = 0): void
{
$stream = $exitCode === 0 ? STDOUT : STDERR;
fwrite($stream, <<<'TEXT'
Usage:
php scripts/migrate-general-picture.php --server=PREFIX --status
php scripts/migrate-general-picture.php --server=PREFIX --apply --backup=/absolute/path/to/pre-migration.sql
php scripts/migrate-general-picture.php --server=PREFIX --apply --server-closed --backup=/absolute/path/to/pre-migration.sql
PREFIX is one configured game directory such as che, kwe, or hwe. Run status,
backup, apply, and verification separately for every game database; this script
never loops over all servers implicitly.
--status is read-only. --apply widens general.picture and the eight emperior
chief picture columns to VARCHAR(64), adds nullable l12imgsvr through l5imgsvr,
and backfills only uniquely matched historical values from ng_old_generals.
It requires a pre-existing, non-empty SQL backup whenever a schema or data
change is needed. Stop web and daemon traffic before applying; MariaDB/Aria DDL
is not transactional. Normally the script acquires and releases the GAME lock.
Use --server-closed only after independently stopping web and daemon traffic;
that flag skips the GAME lock without changing its existing state. Unmatched or
ambiguous historical values remain NULL.
TEXT);
exit($exitCode);
}
/** @return list<int> */
function emperiorPictureLevels(): array
{
return [12, 11, 10, 9, 8, 7, 6, 5];
}
/** @return list<array{string, string, int}> */
function pictureMigrationColumns(): array
{
$columns = [['general', 'picture', 40]];
foreach (emperiorPictureLevels() as $level) {
$columns[] = ['emperior', "l{$level}pic", 32];
}
return $columns;
}
/** @return array<string, mixed>|null */
function migrationColumnInfo(\MeekroDB $db, string $table, string $field): ?array
{
$column = $db->queryFirstRow("SHOW COLUMNS FROM `$table` WHERE Field = %s", $field);
return is_array($column) ? $column : null;
}
function pictureColumnCapacity(\MeekroDB $db, string $table, string $field): ?int
{
$column = migrationColumnInfo($db, $table, $field);
if (!$column || !is_string($column['Type'] ?? null)) {
return null;
}
if (preg_match('/^varchar\((\d+)\)$/i', $column['Type'], $matches) !== 1) {
return null;
}
return (int)$matches[1];
}
function imgsvrColumnIsCompatible(\MeekroDB $db, int $level): bool
{
$column = migrationColumnInfo($db, 'emperior', "l{$level}imgsvr");
if (!$column || !is_string($column['Type'] ?? null)) {
return false;
}
return preg_match('/^(?:tinyint|smallint|mediumint|int|bigint)\(\d+\)(?: unsigned)?$/i', $column['Type']) === 1
&& strtoupper((string)($column['Null'] ?? '')) === 'YES';
}
function pictureMigrationState(\MeekroDB $db): string
{
$needsMigration = false;
foreach (pictureMigrationColumns() as [$table, $field, $legacyCapacity]) {
$capacity = pictureColumnCapacity($db, $table, $field);
if ($capacity === $legacyCapacity) {
$needsMigration = true;
continue;
}
if ($capacity === null || $capacity < 64) {
return 'unsupported';
}
}
foreach (emperiorPictureLevels() as $level) {
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
$needsMigration = true;
continue;
}
if (!imgsvrColumnIsCompatible($db, $level)) {
return 'unsupported';
}
}
return $needsMigration ? 'legacy' : 'ready';
}
function imgsvrColumnsExist(\MeekroDB $db): bool
{
foreach (emperiorPictureLevels() as $level) {
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
return false;
}
}
return true;
}
function unresolvedEmperiorImgsvrCount(\MeekroDB $db): ?int
{
if (!imgsvrColumnsExist($db)) {
return null;
}
$terms = array_map(
static fn(int $level): string => "(`l{$level}imgsvr` IS NULL)",
emperiorPictureLevels(),
);
$count = $db->queryFirstField('SELECT SUM(' . implode(' + ', $terms) . ') FROM emperior');
return $count === null ? 0 : (int)$count;
}
/** @return list<array{no: int|string, imgsvr: int|string}> */
function recoverableEmperiorImgsvrRows(\MeekroDB $db, int $level): array
{
$nameField = "l{$level}name";
$pictureField = "l{$level}pic";
$imgsvrField = "l{$level}imgsvr";
return $db->query(
"SELECT e.`no`, MIN(CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.imgsvr')), '-1') AS SIGNED)) AS `imgsvr`
FROM `emperior` e
JOIN `ng_old_generals` og
ON og.`server_id` = e.`server_id`
AND og.`name` = e.`$nameField`
AND SUBSTRING_INDEX(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.picture')), ''), '?=', 1)
= SUBSTRING_INDEX(COALESCE(e.`$pictureField`, ''), '?=', 1)
AND CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.officer_level')), '-1') AS SIGNED) = %i
WHERE e.`$imgsvrField` IS NULL
GROUP BY e.`no`
HAVING COUNT(*) = 1 AND `imgsvr` IN (0, 1)",
$level,
);
}
function recoverableEmperiorImgsvrCount(\MeekroDB $db): int
{
if (!imgsvrColumnsExist($db)) {
return 0;
}
$count = 0;
foreach (emperiorPictureLevels() as $level) {
$count += count(recoverableEmperiorImgsvrRows($db, $level));
}
return $count;
}
function backfillEmperiorImgsvr(\MeekroDB $db): int
{
$updated = 0;
foreach (emperiorPictureLevels() as $level) {
$field = "l{$level}imgsvr";
foreach (recoverableEmperiorImgsvrRows($db, $level) as $row) {
$db->update(
'emperior',
[$field => (int)$row['imgsvr']],
"`no`=%i AND `$field` IS NULL",
(int)$row['no'],
);
$updated++;
}
}
return $updated;
}
function printPictureMigrationStatus(\MeekroDB $db, string $server): string
{
$state = pictureMigrationState($db);
printf("server=%s\n", $server);
printf("schema_state=%s\n", $state);
foreach (pictureMigrationColumns() as [$table, $field]) {
$capacity = pictureColumnCapacity($db, $table, $field);
$statusKey = $table === 'general' && $field === 'picture'
? 'picture_capacity'
: "{$table}_{$field}_capacity";
printf("%s=%s\n", $statusKey, $capacity ?? 'unknown');
}
foreach (emperiorPictureLevels() as $level) {
$field = "l{$level}imgsvr";
$column = migrationColumnInfo($db, 'emperior', $field);
printf(
"emperior_%s=%s\n",
$field,
$column === null ? 'missing' : strtolower((string)$column['Type']),
);
}
$unresolved = unresolvedEmperiorImgsvrCount($db);
printf("unresolved_emperior_imgsvr=%s\n", $unresolved ?? 'unknown');
return $state;
}
function requirePictureMigrationBackup(mixed $backup): string
{
if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) {
fwrite(STDERR, "--backup must name a pre-existing, non-empty absolute SQL backup made immediately before migration.\n");
exit(2);
}
return $backup;
}
function acquirePictureMigrationLock(\MeekroDB $db, string $server): bool
{
return (int)$db->queryFirstField(
'SELECT GET_LOCK(%s, 0)',
"sammo-picture-migration-$server",
) === 1;
}
function releasePictureMigrationLock(\MeekroDB $db, string $server): void
{
$db->queryFirstField(
'SELECT RELEASE_LOCK(%s)',
"sammo-picture-migration-$server",
);
}
$db = DB::db();
if (isset($options['status'])) {
exit(printPictureMigrationStatus($db, $server) === 'unsupported' ? 2 : 0);
}
$state = pictureMigrationState($db);
if ($state === 'unsupported') {
fwrite(STDERR, "One or more picture columns have an unsupported schema; inspect --status first.\n");
exit(2);
}
$recoverableBefore = $state === 'ready' ? recoverableEmperiorImgsvrCount($db) : 0;
if ($state === 'ready' && $recoverableBefore === 0) {
fwrite(STDOUT, "Picture schema for $server is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n");
printPictureMigrationStatus($db, $server);
exit(0);
}
requirePictureMigrationBackup($options['backup'] ?? null);
$serverClosed = isset($options['server-closed']);
if ($serverClosed) {
fwrite(
STDERR,
"WARNING: --server-closed skips the GAME lock. Continue only if web and daemon traffic for $server is already stopped.\n",
);
}
if (!acquirePictureMigrationLock($db, $server)) {
fwrite(STDERR, "Another picture migration is already running for $server.\n");
exit(3);
}
$backfilled = 0;
$acquiredGameLock = false;
if (!$serverClosed && !\sammo\tryLock()) {
releasePictureMigrationLock($db, $server);
fwrite(
STDERR,
"Unable to acquire the GAME lock. If the server is intentionally closed and all web/daemon traffic is stopped, rerun with --server-closed.\n",
);
exit(3);
}
if (!$serverClosed) {
$acquiredGameLock = true;
}
try {
if (pictureColumnCapacity($db, 'general', 'picture') === 40) {
$db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL');
}
$emperiorClauses = [];
foreach (emperiorPictureLevels() as $level) {
$pictureField = "l{$level}pic";
$imgsvrField = "l{$level}imgsvr";
if (pictureColumnCapacity($db, 'emperior', $pictureField) === 32) {
$emperiorClauses[] = "MODIFY `$pictureField` VARCHAR(64) NULL DEFAULT ''";
}
if (migrationColumnInfo($db, 'emperior', $imgsvrField) === null) {
$emperiorClauses[] = "ADD COLUMN `$imgsvrField` INT(1) NULL DEFAULT NULL AFTER `$pictureField`";
}
}
if ($emperiorClauses !== []) {
$db->query('ALTER TABLE emperior ' . implode(', ', $emperiorClauses));
}
$backfilled = backfillEmperiorImgsvr($db);
} finally {
if ($acquiredGameLock) {
\sammo\unlock();
}
releasePictureMigrationLock($db, $server);
}
if (printPictureMigrationStatus($db, $server) !== 'ready') {
fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n");
exit(4);
}
printf("Picture-column migration for %s completed; backfilled_imgsvr=%d.\n", $server, $backfilled);
+20
View File
@@ -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);
+21 -9
View File
@@ -6,20 +6,28 @@ phases AS (
g.winner_nation, g.winner_nation,
e.l12name, e.l12name,
e.l12pic, e.l12pic,
e.l12imgsvr,
e.l11name, e.l11name,
e.l11pic, e.l11pic,
e.l11imgsvr,
e.l10name, e.l10name,
e.l10pic, e.l10pic,
e.l10imgsvr,
e.l9name, e.l9name,
e.l9pic, e.l9pic,
e.l9imgsvr,
e.l8name, e.l8name,
e.l8pic, e.l8pic,
e.l8imgsvr,
e.l7name, e.l7name,
e.l7pic, e.l7pic,
e.l7imgsvr,
e.l6name, e.l6name,
e.l6pic, e.l6pic,
e.l6imgsvr,
e.l5name, e.l5name,
e.l5pic e.l5pic,
e.l5imgsvr
FROM emperior e FROM emperior e
LEFT JOIN ng_games g ON g.server_id = e.server_id LEFT JOIN ng_games g ON g.server_id = e.server_id
WHERE e.no BETWEEN 1 AND 99 WHERE e.no BETWEEN 1 AND 99
@@ -63,21 +71,21 @@ selection_reasons AS (
WHERE hall_rank <= 10 WHERE hall_rank <= 10
), ),
chief_slots AS ( chief_slots AS (
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture FROM phases SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture, l12imgsvr AS imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic, l11imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic, l10imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic, l9imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic, l8imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic, l7imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic, l6imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic, l5imgsvr FROM phases
), ),
chief_reasons AS ( chief_reasons AS (
SELECT SELECT
@@ -94,6 +102,10 @@ chief_reasons AS (
'?=', '?=',
1 1
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1) ) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
AND (
c.imgsvr IS NULL
OR CAST(COALESCE(JSON_VALUE(og.data, '$.imgsvr'), -1) AS SIGNED) = c.imgsvr
)
AND ( AND (
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
OR ( OR (
+77
View File
@@ -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;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace sammo;
final class RemoteImageUploadException extends \RuntimeException
{
}
final class RemoteUserIconUploadClient
{
private const SAFE_EXACT_ERRORS = [
'Invalid image upload client',
'Image upload secret must be at least 32 characters',
'Invalid image upload URL',
'Image upload URL must use HTTPS except for loopback tests',
'Remote user icon upload is not configured',
'Remote user icon upload secret file is not configured',
'Remote user icon upload secret file cannot be read',
'Remote user icon upload secret is too short',
'Unable to initialize image upload request',
'Image upload returned invalid JSON',
'Image upload returned an unsuccessful response',
'Image upload returned an unexpected path',
];
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}";
}
/**
* Record a caught upload failure in both the PHP service log and the
* operator-facing SQLite log without persisting request arguments, response
* bodies, headers, or secret values.
*/
public static function logFailure(
string $operation,
\Throwable $error,
?callable $systemLogger = null,
?callable $structuredLogger = null
): void {
$label = match ($operation) {
'user-icon' => 'Remote user icon upload',
'content-image' => 'Remote content image upload',
default => 'Remote image upload',
};
$reason = self::safeFailureReason($error);
$message = "{$label} failed: {$reason}";
if ($systemLogger === null) {
error_log($message);
} else {
$systemLogger($message);
}
try {
$arguments = [
'RemoteImageUploadFailure',
$message,
$error->getFile() . ':' . $error->getLine(),
[],
];
if ($structuredLogger === null) {
logError(...$arguments);
} else {
$structuredLogger(...$arguments);
}
} catch (\Throwable $loggingError) {
error_log('Remote image upload structured logging failed: ' . get_debug_type($loggingError));
}
}
private static function safeFailureReason(\Throwable $error): string
{
if ($error instanceof RemoteImageUploadException || $error instanceof \InvalidArgumentException) {
$message = $error->getMessage();
if (in_array($message, self::SAFE_EXACT_ERRORS, true)
|| ($error instanceof RemoteImageUploadException
&& preg_match('/^(?:Image upload rejected \([1-5][0-9]{2}\)|Image upload request failed \(cURL [0-9]+\))$/D', $message))) {
return $message;
}
}
return 'Unexpected ' . get_debug_type($error);
}
/** @return array{string,string} */
private static function configuredBaseUrlAndSecret(): array
{
if (!self::isConfiguredEnabled()
|| !property_exists(ServConfig::class, 'remoteUserIconUploadPath')
|| !property_exists(ServConfig::class, 'remoteUserIconUploadSecretFile')) {
throw new RemoteImageUploadException('Remote user icon upload is not configured');
}
$secretPath = ServConfig::$remoteUserIconUploadSecretFile;
if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) {
throw new RemoteImageUploadException('Remote user icon upload secret file is not configured');
}
if ($secretPath[0] !== '/') {
$secretPath = ROOT . '/' . $secretPath;
}
$secretContents = @file_get_contents($secretPath);
if ($secretContents === false) {
throw new RemoteImageUploadException('Remote user icon upload secret file cannot be read');
}
$secret = trim($secretContents);
if (strlen($secret) < 32) {
throw new RemoteImageUploadException('Remote user icon upload secret is too short');
}
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 RemoteImageUploadException('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);
$curlErrorNumber = curl_errno($curl);
curl_close($curl);
if ($response === false) {
throw new RemoteImageUploadException("Image upload request failed (cURL {$curlErrorNumber})");
}
if ($status < 200 || $status >= 300) {
throw new RemoteImageUploadException("Image upload rejected ({$status})");
}
try {
$decoded = Json::decode($response);
} catch (\Throwable $error) {
throw new RemoteImageUploadException('Image upload returned invalid JSON', previous: $error);
}
if (!($decoded['ok'] ?? false)) {
throw new RemoteImageUploadException('Image upload returned an unsuccessful response');
}
$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 RemoteImageUploadException('Image upload returned an unexpected path');
}
return $decoded;
}
}
+51 -1
View File
@@ -146,14 +146,64 @@ final class GameClockBoundaryTest extends TestCase
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue')); 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'); $source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
self::assertIsString($source); self::assertIsString($source);
self::assertStringContainsString(
'$usesLogicalClock = GameClock::isInitialized($gameStor);',
$source,
);
self::assertStringContainsString( self::assertStringContainsString(
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));', '$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
$source, $source,
); );
self::assertStringContainsString(
'$admin[\'isOpen\'] = new \\DateTimeImmutable((string)$admin[\'opentime\']) <= GameClock::readWallTime();',
$source,
);
}
public function testNationGeneralListProjectsStoredTimesAtApiBoundary(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/sammo/API/Nation/GeneralList.php');
self::assertIsString($source);
self::assertStringContainsString(
'$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;',
$source,
);
self::assertStringContainsString(
'$env[\'turntime\'] = $formatStoredTime($env[\'turntime\']);',
$source,
);
self::assertStringContainsString(
'$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID][\'turntime\']);',
$source,
);
self::assertStringContainsString(
"? (string)\$value",
$source,
);
} }
} }
+4
View File
@@ -109,6 +109,10 @@ final class GameClockTest extends TestCase
]); ]);
self::assertFalse(GameClock::isInitialized($legacyStorage)); 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 = $this->createMock(KVStorage::class);
$partialStorage->method('getValues')->willReturn([ $partialStorage->method('getValues')->willReturn([
'clock_tick' => 0, 'clock_tick' => 0,
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
final class GeneralPictureSchemaTest extends TestCase
{
/**
* @param list<string> $arguments
* @return array{int, string, string}
*/
private function runMigrationCommand(array $arguments): array
{
$command = array_merge(
[PHP_BINARY, __DIR__ . '/../scripts/migrate-general-picture.php'],
$arguments,
);
$pipes = [];
$process = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);
self::assertIsResource($process);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
self::assertIsString($stdout);
self::assertIsString($stderr);
return [$exitCode, $stdout, $stderr];
}
public function testGameAndAccountSchemasAcceptRemoteUserIconPaths(): void
{
$gameSchema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql');
$accountSchema = file_get_contents(__DIR__ . '/../f_install/sql/common_schema.sql');
self::assertIsString($gameSchema);
self::assertIsString($accountSchema);
self::assertMatchesRegularExpression('/`picture`\s+VARCHAR\(64\)\s+NOT NULL/i', $gameSchema);
self::assertMatchesRegularExpression('/`PICTURE`\s+VARCHAR\(64\)/i', $accountSchema);
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
self::assertMatchesRegularExpression(
sprintf('/`l%dpic`\s+VARCHAR\(64\)/i', $level),
$gameSchema,
);
self::assertMatchesRegularExpression(
sprintf('/`l%dimgsvr`\s+INT\(1\)\s+NULL\s+DEFAULT\s+NULL/i', $level),
$gameSchema,
);
}
$longestRemotePath = 'users/core/' . str_repeat('a', 32) . '.jpeg?=20260807';
self::assertGreaterThan(40, strlen($longestRemotePath));
self::assertLessThanOrEqual(64, strlen($longestRemotePath));
}
public function testExistingGameMigrationWidensAllConstrainedPictureColumns(): void
{
$migration = file_get_contents(__DIR__ . '/../scripts/migrate-general-picture.php');
self::assertIsString($migration);
self::assertStringContainsString(
'ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL',
$migration,
);
self::assertStringContainsString('"l{$level}pic"', $migration);
self::assertStringContainsString('"l{$level}imgsvr"', $migration);
self::assertStringContainsString("ALTER TABLE emperior", $migration);
self::assertStringContainsString("ADD COLUMN `\$imgsvrField` INT(1) NULL DEFAULT NULL", $migration);
self::assertStringContainsString('HAVING COUNT(*) = 1', $migration);
self::assertStringContainsString('ambiguous historical values remain NULL', $migration);
self::assertStringContainsString("? 'picture_capacity'", $migration);
self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']", $migration);
self::assertStringContainsString("require \$serverDirectory . '/lib.php'", $migration);
self::assertStringContainsString("require \$serverDirectory . '/func.php'", $migration);
self::assertStringNotContainsString("'/hwe/lib.php'", $migration);
self::assertStringNotContainsString("'/hwe/func.php'", $migration);
self::assertStringContainsString('SELECT GET_LOCK(%s, 0)', $migration);
self::assertStringContainsString('SELECT RELEASE_LOCK(%s)', $migration);
self::assertStringContainsString('if (!$serverClosed && !\\sammo\\tryLock())', $migration);
self::assertStringContainsString('if ($acquiredGameLock)', $migration);
self::assertStringContainsString('--server-closed skips the GAME lock', $migration);
self::assertStringNotContainsString('UPDATE general', $migration);
self::assertStringContainsString("\$state === 'ready'", $migration);
}
public function testUnificationPreservesChiefImageServerAndCentennialMatchingUsesIt(): void
{
$gameRule = file_get_contents(__DIR__ . '/../hwe/func_gamerule.php');
$candidateSql = file_get_contents(__DIR__ . '/../src/centennial_allstar_candidates.sql');
self::assertIsString($gameRule);
self::assertIsString($candidateSql);
self::assertStringContainsString('name,picture,imgsvr,belong,officer_level', $gameRule);
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
self::assertStringContainsString(
"'l{$level}imgsvr' => \$chiefs[{$level}]['imgsvr']",
$gameRule,
);
self::assertStringContainsString("e.l{$level}imgsvr", $candidateSql);
}
self::assertStringContainsString('c.imgsvr IS NULL', $candidateSql);
self::assertStringContainsString("JSON_VALUE(og.data, '$.imgsvr')", $candidateSql);
}
public function testMigrationRequiresAnExplicitSafeServerPrefixBeforeLoadingConfiguration(): void
{
[$helpExit, $helpOutput, $helpError] = $this->runMigrationCommand(['--help']);
self::assertSame(0, $helpExit);
self::assertStringContainsString('--server=PREFIX', $helpOutput);
self::assertStringContainsString('--server-closed', $helpOutput);
self::assertSame('', $helpError);
[$missingExit, $missingOutput, $missingError] = $this->runMigrationCommand(['--status']);
self::assertSame(2, $missingExit);
self::assertSame('', $missingOutput);
self::assertStringContainsString('--server must name one game-server directory', $missingError);
[$traversalExit, $traversalOutput, $traversalError] = $this->runMigrationCommand([
'--server=../hwe',
'--status',
]);
self::assertSame(2, $traversalExit);
self::assertSame('', $traversalOutput);
self::assertStringContainsString('--server must name one game-server directory', $traversalError);
}
public function testScriptsDirectoryIsDeniedOverApache(): void
{
$accessRules = file_get_contents(__DIR__ . '/../scripts/.htaccess');
self::assertIsString($accessRules);
self::assertStringContainsString('Require all denied', $accessRules);
self::assertStringContainsString('Deny from all', $accessRules);
}
}
+35
View File
@@ -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');
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\RemoteImageUploadException;
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']));
}
public function testLogFailureWritesSafeOperatorEntryWithoutTraceOrSecret(): void
{
$secret = 'secret-' . str_repeat('z', 64);
$systemMessages = [];
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'user-icon',
new RuntimeException("request failed with {$secret}"),
static function (string $message) use (&$systemMessages): void {
$systemMessages[] = $message;
},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(['Remote user icon upload failed: Unexpected RuntimeException'], $systemMessages);
self::assertCount(1, $structuredEntries);
self::assertSame('RemoteImageUploadFailure', $structuredEntries[0]['type']);
self::assertSame($systemMessages[0], $structuredEntries[0]['message']);
self::assertSame([], $structuredEntries[0]['trace']);
self::assertStringNotContainsString($secret, json_encode($structuredEntries, JSON_THROW_ON_ERROR));
}
public function testLogFailureKeepsOnlyClientGeneratedSafeReason(): void
{
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'content-image',
new RemoteImageUploadException('Image upload rejected (401)'),
static function (): void {},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(
'Remote content image upload failed: Image upload rejected (401)',
$structuredEntries[0]['message']
);
self::assertSame([], $structuredEntries[0]['trace']);
}
public function testLogFailureRejectsAnUnapprovedClientExceptionMessage(): void
{
$secret = 'secret-' . str_repeat('q', 64);
$structuredEntries = [];
RemoteUserIconUploadClient::logFailure(
'user-icon',
new RemoteImageUploadException("unexpected response {$secret}"),
static function (): void {},
static function (string $type, string $message, string $path, array $trace) use (&$structuredEntries): void {
$structuredEntries[] = compact('type', 'message', 'path', 'trace');
}
);
self::assertSame(
'Remote user icon upload failed: Unexpected sammo\\RemoteImageUploadException',
$structuredEntries[0]['message']
);
self::assertStringNotContainsString($secret, json_encode($structuredEntries, JSON_THROW_ON_ERROR));
}
}