Compare commits

...
15 Commits
27 changed files with 1270 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
```
> 이미지는 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
View File
@@ -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>
+21 -7
View File
@@ -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,
+12 -2
View File
@@ -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
);
+30 -3
View File
@@ -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
View File
@@ -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
),
+9 -1
View File
@@ -780,7 +780,7 @@ function checkEmperior()
$chiefs = Util::convertArrayToDict(
$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
),
'officer_level'
@@ -893,20 +893,28 @@ function checkEmperior()
'rice' => $nation['rice'],
'l12name' => $chiefs[12]['name'],
'l12pic' => $chiefs[12]['picture'],
'l12imgsvr' => $chiefs[12]['imgsvr'],
'l11name' => $chiefs[11]['name'],
'l11pic' => $chiefs[11]['picture'],
'l11imgsvr' => $chiefs[11]['imgsvr'],
'l10name' => $chiefs[10]['name'],
'l10pic' => $chiefs[10]['picture'],
'l10imgsvr' => $chiefs[10]['imgsvr'],
'l9name' => $chiefs[9]['name'],
'l9pic' => $chiefs[9]['picture'],
'l9imgsvr' => $chiefs[9]['imgsvr'],
'l8name' => $chiefs[8]['name'],
'l8pic' => $chiefs[8]['picture'],
'l8imgsvr' => $chiefs[8]['imgsvr'],
'l7name' => $chiefs[7]['name'],
'l7pic' => $chiefs[7]['picture'],
'l7imgsvr' => $chiefs[7]['imgsvr'],
'l6name' => $chiefs[6]['name'],
'l6pic' => $chiefs[6]['picture'],
'l6imgsvr' => $chiefs[6]['imgsvr'],
'l5name' => $chiefs[5]['name'],
'l5pic' => $chiefs[5]['picture'],
'l5imgsvr' => $chiefs[5]['imgsvr'],
'tiger' => $tigerstr,
'eagle' => $eaglestr,
'gen' => $gen,
+13 -5
View File
@@ -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
View File
@@ -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[] */
+27 -15
View File
@@ -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) {
RemoteUserIconUploadClient::logFailure('content-image', $error);
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,
];
}
}
+8 -4
View File
@@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
$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(
'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)) {
continue;
}
$troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime'];
$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']);
$troops[$troopLeaderID] = new ArrayObject([
'id' => $troopLeaderID,
'name' => $troopName,
@@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
//'0000-00-00 11:23';
'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor)
->formatTick(Util::toInt($rawGeneral['turntime'])),
'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19),
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
? null
: GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])),
: substr($formatStoredTime($rawGeneral['recent_war']), 0, 19),
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
@@ -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']);
+17 -9
View File
@@ -11,7 +11,7 @@ CREATE TABLE `general` (
`bornyear` INT(3) NULL DEFAULT '180',
`deadyear` INT(3) NULL DEFAULT '300',
`newmsg` INT(1) NULL DEFAULT '0',
`picture` VARCHAR(40) NOT NULL,
`picture` VARCHAR(64) NOT NULL,
`imgsvr` INT(1) NOT NULL DEFAULT '0',
`name` VARCHAR(32) NOT 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',
`rice` INT(9) NULL DEFAULT '0',
`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 '',
`l11pic` VARCHAR(32) NULL DEFAULT '',
`l11pic` VARCHAR(64) NULL DEFAULT '',
`l11imgsvr` INT(1) NULL DEFAULT NULL,
`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 '',
`l9pic` VARCHAR(32) NULL DEFAULT '',
`l9pic` VARCHAR(64) NULL DEFAULT '',
`l9imgsvr` INT(1) NULL DEFAULT NULL,
`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 '',
`l7pic` VARCHAR(32) NULL DEFAULT '',
`l7pic` VARCHAR(64) NULL DEFAULT '',
`l7imgsvr` INT(1) NULL DEFAULT NULL,
`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 '',
`l5pic` VARCHAR(32) NULL DEFAULT '',
`l5pic` VARCHAR(64) NULL DEFAULT '',
`l5imgsvr` INT(1) NULL DEFAULT NULL,
`tiger` VARCHAR(128) NULL DEFAULT '',
`eagle` VARCHAR(128) NULL DEFAULT '',
`gen` TEXT NULL DEFAULT '',
+10 -7
View File
@@ -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 () {
});
});
+25 -3
View File
@@ -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) {
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['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
View File
@@ -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();
}
+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>
+334
View File
@@ -0,0 +1,334 @@
#!/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 --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. --apply requires --server-closed as an explicit operator
confirmation that web and daemon traffic has already been stopped. The script
does not acquire or alter the GAME lock; it only takes a separate MariaDB named
lock to prevent two picture migrations from running together. 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: Picture migration must run while $server web and daemon traffic is stopped. After stopping them, rerun with --server-closed.\n",
);
exit(3);
}
fwrite(
STDERR,
"WARNING: --server-closed is an operator confirmation; this script cannot verify that $server web and daemon traffic is stopped.\n",
);
if (!acquirePictureMigrationLock($db, $server)) {
fwrite(STDERR, "Another picture migration is already running for $server.\n");
exit(3);
}
$backfilled = 0;
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 {
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,
e.l12name,
e.l12pic,
e.l12imgsvr,
e.l11name,
e.l11pic,
e.l11imgsvr,
e.l10name,
e.l10pic,
e.l10imgsvr,
e.l9name,
e.l9pic,
e.l9imgsvr,
e.l8name,
e.l8pic,
e.l8imgsvr,
e.l7name,
e.l7pic,
e.l7imgsvr,
e.l6name,
e.l6pic,
e.l6imgsvr,
e.l5name,
e.l5pic
e.l5pic,
e.l5imgsvr
FROM emperior e
LEFT JOIN ng_games g ON g.server_id = e.server_id
WHERE e.no BETWEEN 1 AND 99
@@ -63,21 +71,21 @@ selection_reasons AS (
WHERE hall_rank <= 10
),
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
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
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
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
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
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
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
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 (
SELECT
@@ -94,6 +102,10 @@ chief_reasons AS (
'?=',
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 (
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
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'));
}
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,
);
}
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));
$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,
+137
View File
@@ -0,0 +1,137 @@
<?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)', $migration);
self::assertStringContainsString('--server-closed is an operator confirmation', $migration);
self::assertStringNotContainsString('\\sammo\\tryLock()', $migration);
self::assertStringNotContainsString('\\sammo\\unlock()', $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));
}
}