diff --git a/README.md b/README.md index 4c6658d8..e31fe02a 100644 --- a/README.md +++ b/README.md @@ -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에게 문의하여 주십시오. \ No newline at end of file +만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. diff --git a/f_install/install.php b/f_install/install.php index 751fe4da..b4f9b838 100644 --- a/f_install/install.php +++ b/f_install/install.php @@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
- +
- + +
+
+ +
+ +
+
- +
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
- \ No newline at end of file + diff --git a/f_install/j_setup_db.php b/f_install/j_setup_db.php index 96471480..88044563 100644 --- a/f_install/j_setup_db.php +++ b/f_install/j_setup_db.php @@ -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, diff --git a/f_install/j_update_js_css_path.php b/f_install/j_update_js_css_path.php index e561e3b9..8b4ebcd1 100644 --- a/f_install/j_update_js_css_path.php +++ b/f_install/j_update_js_css_path.php @@ -33,7 +33,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); @@ -64,7 +69,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); diff --git a/f_install/templates/ServConfig.orig.php b/f_install/templates/ServConfig.orig.php index 9160da14..7ac66e3e 100644 --- a/f_install/templates/ServConfig.orig.php +++ b/f_install/templates/ServConfig.orig.php @@ -13,6 +13,9 @@ class ServConfig public static $gameImagePath = "_tK_gameImagePath_"; public static $imageRequestPath = "_tK_imageRequestPath_"; public static $imageRequestKey = '_tK_imageRequestKey_'; + public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_; + public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_'; + public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_'; private static $serverList = null; public static function getSharedIconPath(string $filepath = ''): string @@ -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; } /** diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index 0991986e..c8432a0f 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -800,6 +800,7 @@ function checkEmperior() $gameStor->isunited = 2; $gameStor->refreshLimit = $gameStor->refreshLimit * 100; + AutoResetProgress::record($gameStor); foreach ($db->queryFirstColumn('SELECT no FROM general WHERE npc<2 AND age>=%i', GameConst::$minPushHallAge) as $hallGeneralNo) { CheckHall($hallGeneralNo); @@ -811,7 +812,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' @@ -924,20 +925,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, diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index 92e86b96..4cfc30f1 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -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']); diff --git a/hwe/sammo/API/Misc/UploadImage.php b/hwe/sammo/API/Misc/UploadImage.php index 36e06043..519a97d6 100644 --- a/hwe/sammo/API/Misc/UploadImage.php +++ b/hwe/sammo/API/Misc/UploadImage.php @@ -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, ]; } } diff --git a/hwe/sammo/API/Nation/GeneralList.php b/hwe/sammo/API/Nation/GeneralList.php index 26001519..31e3e3c8 100644 --- a/hwe/sammo/API/Nation/GeneralList.php +++ b/hwe/sammo/API/Nation/GeneralList.php @@ -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, diff --git a/hwe/sammo/Event/Action/InvaderEnding.php b/hwe/sammo/Event/Action/InvaderEnding.php index aa458fbc..328f2190 100644 --- a/hwe/sammo/Event/Action/InvaderEnding.php +++ b/hwe/sammo/Event/Action/InvaderEnding.php @@ -2,6 +2,7 @@ namespace sammo\Event\Action; use sammo\ActionLogger; +use sammo\AutoResetProgress; use sammo\CityConst; use sammo\DB; use sammo\KVStorage; @@ -61,6 +62,7 @@ class InvaderEnding extends \sammo\Event\Action{ $logger->pushGlobalHistoryLog("【이벤트】백성은 언젠가 영웅이 나타나길 기다립니다."); } $gameStor->setValue('isunited', 3); + AutoResetProgress::record($gameStor); $logger->flush(); $gameStor->refreshLimit = $gameStor->refreshLimit * 100; @@ -71,4 +73,4 @@ class InvaderEnding extends \sammo\Event\Action{ return [__CLASS__, 'Deleted']; } -} \ No newline at end of file +} diff --git a/hwe/sammo/Event/Action/RaiseInvader.php b/hwe/sammo/Event/Action/RaiseInvader.php index dacef71f..31124777 100644 --- a/hwe/sammo/Event/Action/RaiseInvader.php +++ b/hwe/sammo/Event/Action/RaiseInvader.php @@ -4,6 +4,7 @@ namespace sammo\Event\Action; use Ds\Set; use sammo\ActionLogger; +use sammo\AutoResetProgress; use sammo\CityConst; use sammo\DB; use sammo\Json; @@ -119,6 +120,7 @@ class RaiseInvader extends \sammo\Event\Action $gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor->setValue('isunited', 1); + AutoResetProgress::record($gameStor); $turnterm = $gameStor->turnterm; $generalCnt = $db->queryFirstField('SELECT count(*) FROM general'); diff --git a/hwe/sammo/InheritancePointManager.php b/hwe/sammo/InheritancePointManager.php index d185d607..b0e9e5a4 100644 --- a/hwe/sammo/InheritancePointManager.php +++ b/hwe/sammo/InheritancePointManager.php @@ -152,19 +152,7 @@ class InheritancePointManager }; switch ($key) { case InheritanceKey::dex: - $extractFn = function () use ($general, $multiplier) { - $dexLimit = Util::array_last(getDexLevelList())[0]; - $totalDex = 0; - foreach (array_keys(GameUnitConst::allType()) as $armType) { - $subDex = $general->getVar("dex{$armType}"); - if ($subDex > $dexLimit) { - $totalDex += ($subDex - $dexLimit) / 3; - $subDex = $dexLimit; - } - $totalDex += $subDex; - } - return [$totalDex * $multiplier, null]; - }; + $extractFn = fn () => [$this->getDexInheritancePoint($general), null]; break; case InheritanceKey::betting: $extractFn = function () use ($general, $multiplier) { @@ -189,6 +177,28 @@ class InheritancePointManager return $value; } + /** @param null|int[] $armTypes */ + public function getDexInheritancePoint( + General $general, + ?int $dexLimit = null, + ?array $armTypes = null + ): float + { + $dexLimit ??= Util::array_last(getDexLevelList())[0]; + $armTypes ??= array_keys(GameUnitConst::allType()); + $multiplier = $this->getInheritancePointType(InheritanceKey::dex)->pointCoeff; + $totalDex = 0; + foreach ($armTypes as $armType) { + $subDex = CentennialAllStarGrowthService::recordableValue($general, "dex{$armType}"); + if ($subDex > $dexLimit) { + $totalDex += ($subDex - $dexLimit) / 3; + $subDex = $dexLimit; + } + $totalDex += $subDex; + } + return $totalDex * $multiplier; + } + public function setInheritancePoint(General $general, InheritanceKey $key, $value, $aux = null) { diff --git a/hwe/sammo/TurnExecutionHelper.php b/hwe/sammo/TurnExecutionHelper.php index ec5d6287..52c6e8ba 100644 --- a/hwe/sammo/TurnExecutionHelper.php +++ b/hwe/sammo/TurnExecutionHelper.php @@ -39,6 +39,18 @@ class TurnExecutionHelper { return max($completedTick, $candidateTick); } + + private static function recordInvaderProgress(KVStorage $gameStorage, int $beforeTick): void + { + if (Util::toInt($gameStorage->isunited) !== 1) { + return; + } + AutoResetProgress::recordIfAdvanced( + $gameStorage, + $beforeTick, + Util::toInt($gameStorage->turntime), + ); + } public function __destruct() { @@ -637,6 +649,8 @@ class TurnExecutionHelper $locked = true; return $gameStor->turntime; } + + $completionTickBeforeRun = Util::toInt($gameStor->turntime); $gameStor->cacheAll(); // 1턴이상 갱신 없었으면 서버 지연 @@ -683,6 +697,7 @@ class TurnExecutionHelper $currentTurn, ); } + self::recordInvaderProgress($gameStor, $completionTickBeforeRun); unlock(); return $gameStor->turntime; } @@ -768,6 +783,7 @@ class TurnExecutionHelper processTournament(); //거래 처리 processAuction(); + self::recordInvaderProgress($gameStor, $completionTickBeforeRun); // 잠금 해제 $turntime = $gameStor->turntime; diff --git a/hwe/sql/schema.sql b/hwe/sql/schema.sql index 6eeefdfb..46f9bef5 100644 --- a/hwe/sql/schema.sql +++ b/hwe/sql/schema.sql @@ -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 '', diff --git a/hwe/test-ts/loginFlow.test.ts b/hwe/test-ts/loginFlow.test.ts new file mode 100644 index 00000000..0e7ff076 --- /dev/null +++ b/hwe/test-ts/loginFlow.test.ts @@ -0,0 +1,44 @@ +import { assert } from 'chai'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow'; + +describe('automatic login failure flow', () => { + it('prompts for OTP without treating the challenge as a terminal login failure', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }, 0), + 'prompt_otp' + ); + assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.'); + }); + + it('keeps the existing retry and alert behavior for non-OTP failures', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '자동 로그인: 절차 오류', + }, 0), + 'retry' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '로그인할 수 없습니다.', + }, 1), + 'alert' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: true, + reason: 'failed', + }, 1), + 'silent' + ); + }); +}); diff --git a/hwe/ts/defs/API/Login.ts b/hwe/ts/defs/API/Login.ts index 5bf0ca26..18fc5503 100644 --- a/hwe/ts/defs/API/Login.ts +++ b/hwe/ts/defs/API/Login.ts @@ -36,5 +36,6 @@ export type AutoLoginResponse = { export type AutoLoginFailed = { result: false, silent: boolean, + reqOTP?: boolean, reason: string, -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/install.ts b/hwe/ts/gateway/install.ts index c39501e2..32f0ffb8 100644 --- a/hwe/ts/gateway/install.ts +++ b/hwe/ts/gateway/install.ts @@ -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 () { -}); \ No newline at end of file +}); diff --git a/hwe/ts/gateway/login.ts b/hwe/ts/gateway/login.ts index 984fadad..adde525c 100644 --- a/hwe/ts/gateway/login.ts +++ b/hwe/ts/gateway/login.ts @@ -12,8 +12,9 @@ import { delay } from '@util/delay'; import { Modal } from 'bootstrap'; import '@/gateway/common'; import { isString } from 'lodash-es'; -import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; -import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; +import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow'; declare global { interface Window { getOAuthToken: (mode: string, scope_list: string[]) => void; @@ -51,9 +52,20 @@ function getToken(): [number, string] | undefined { return token; } -function resetToken() { - localStorage.removeItem(LOGIN_TOKEN_KEY); -} +function resetToken() { + localStorage.removeItem(LOGIN_TOKEN_KEY); +} + +function showOTPModal() { + const modalEl = unwrap(document.querySelector('#modalOTP')); + if (!modalOTP) { + modalOTP = new Modal(modalEl); + modalEl.addEventListener('shown.bs.modal', function () { + unwrap(document.querySelector('#otp_code')).focus(); + }); + } + modalOTP.show(); +} async function tryAutoLogin() { try { @@ -93,13 +105,18 @@ async function tryAutoLogin() { }, true); if (!loginResult.result) { - if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) { + const failureAction = classifyAutoLoginFailure(loginResult, attempt); + if (failureAction === 'retry') { console.warn('auto login failed by procedure error. retrying once.'); await delay(150); continue; } - - if (!loginResult.silent) { + if (failureAction === 'prompt_otp') { + alert(OTP_REQUIRED_MESSAGE); + showOTPModal(); + return; + } + if (failureAction === 'alert') { alert(loginResult.reason); } console.error(loginResult.reason); @@ -208,14 +225,7 @@ async function doLoginUsingOAuth() { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); } function postOAuthResult(mode: string) { @@ -314,14 +324,7 @@ $(async function ($) { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); }); $('#otp_form').on('submit', async function (e) { @@ -393,4 +396,4 @@ window.fitIframe = function () { const iframe = unwrap(document.querySelector('#running_map'));//TODO: 근황 여러개 볼 수 있도록? const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight; iframe.style.height = `${scrollHeight}px`; -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/loginFlow.ts b/hwe/ts/gateway/loginFlow.ts new file mode 100644 index 00000000..25a8d5a2 --- /dev/null +++ b/hwe/ts/gateway/loginFlow.ts @@ -0,0 +1,18 @@ +import type { AutoLoginFailed } from '@/defs/API/Login'; + +export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.'; + +export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent'; + +export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction { + if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) { + return 'retry'; + } + if (result.reqOTP) { + return 'prompt_otp'; + } + if (result.silent) { + return 'silent'; + } + return 'alert'; +} diff --git a/i_entrance/j_icon_change.php b/i_entrance/j_icon_change.php index afc1e2b1..3e7bb3a8 100644 --- a/i_entrance/j_icon_change.php +++ b/i_entrance/j_icon_change.php @@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) { break; } - if(!move_uploaded_file($image['tmp_name'], $dest)) { + if (RemoteUserIconUploadClient::isConfiguredEnabled()) { + try { + $remoteName = bin2hex(random_bytes(16)).$newExt; + $contentType = image_type_to_mime_type($imageType); + RemoteUserIconUploadClient::uploadConfigured( + $remoteName, + $contentType, + (string)file_get_contents($image['tmp_name']) + ); + $newPicName = "users/core/{$remoteName}"; + $storedRemotely = true; + } catch (\Throwable $error) { + 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); \ No newline at end of file +Json::die($response); diff --git a/j_updateServer.php b/j_updateServer.php index 7e8abf4b..53c8675e 100644 --- a/j_updateServer.php +++ b/j_updateServer.php @@ -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(); } diff --git a/scripts/.htaccess b/scripts/.htaccess new file mode 100644 index 00000000..a4738cfd --- /dev/null +++ b/scripts/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php new file mode 100755 index 00000000..8cd896b5 --- /dev/null +++ b/scripts/migrate-general-picture.php @@ -0,0 +1,334 @@ +#!/usr/bin/env php + */ +function emperiorPictureLevels(): array +{ + return [12, 11, 10, 9, 8, 7, 6, 5]; +} + +/** @return list */ +function pictureMigrationColumns(): array +{ + $columns = [['general', 'picture', 40]]; + foreach (emperiorPictureLevels() as $level) { + $columns[] = ['emperior', "l{$level}pic", 32]; + } + return $columns; +} + +/** @return array|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 */ +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); diff --git a/scripts/sync-image-repository.php b/scripts/sync-image-repository.php new file mode 100644 index 00000000..2c19b146 --- /dev/null +++ b/scripts/sync-image-repository.php @@ -0,0 +1,20 @@ + false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => $reason, + ]; + } + + public static function shouldDiscardTokenAfterKakaoFailure(bool $reqOTP): bool + { + return !$reqOTP; + } + + public static function pendingSessionTokenID(bool $reqOTP, int $tokenID): ?int + { + return $reqOTP ? $tokenID : null; + } + public function getRequiredSessionMode(): int { return \sammo\BaseAPI::NO_LOGIN; @@ -117,13 +141,24 @@ class LoginByToken extends LoginByID if ($userInfo['oauth_type'] == 'KAKAO') { $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); if ($oauthFailResult !== null) { - $session->login($userInfo['no'], $userInfo['name'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; - $RootDB->delete( - 'login_token', - 'id = %i', $token_id + $session->login( + $userInfo['no'], + $userInfo['name'], + $userInfo['grade'], + true, + $userInfo['token_valid_until'], + static::pendingSessionTokenID($oauthReqOTP, $token_id), + Json::decode($userInfo['acl'] ?? '{}') ); - return $oauthFailReason; + if (static::shouldDiscardTokenAfterKakaoFailure($oauthReqOTP)) { + $RootDB->delete( + 'login_token', + 'id = %i', + $token_id + ); + } + return static::kakaoFailureResponse($oauthReqOTP, $oauthFailReason); } } diff --git a/src/sammo/AutoResetProgress.php b/src/sammo/AutoResetProgress.php new file mode 100644 index 00000000..8290bae9 --- /dev/null +++ b/src/sammo/AutoResetProgress.php @@ -0,0 +1,40 @@ +setValue(self::STORAGE_KEY, TimeUtil::format($wallTime, true)); + } + + public static function recordIfAdvanced( + KVStorage $gameStorage, + int $beforeTick, + int $afterTick, + ?\DateTimeInterface $wallTime = null, + ): bool { + if ($afterTick <= $beforeTick) { + return false; + } + self::record($gameStorage, $wallTime); + return true; + } +} diff --git a/src/sammo/ImageSyncClient.php b/src/sammo/ImageSyncClient.php new file mode 100644 index 00000000..c49c9db8 --- /dev/null +++ b/src/sammo/ImageSyncClient.php @@ -0,0 +1,77 @@ +,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 */ + 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; + } +} diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php new file mode 100644 index 00000000..047ca72d --- /dev/null +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -0,0 +1,235 @@ + */ + 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 */ + 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,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 */ + 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; + } +} diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 579c0064..ccca836d 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -5,6 +5,7 @@ use sammo\CentennialAllStarGrowth; use sammo\CentennialAllStarGrowthService; use sammo\General; use sammo\GameConst; +use sammo\InheritancePointManager; $loader = require __DIR__ . '/../vendor/autoload.php'; $loader->addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true); @@ -371,6 +372,28 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(900000, CentennialAllStarGrowth::dexFloor(900000, 1)); } + public function testSynchronizedDexDoesNotIncreaseInheritancePoint(): void + { + $vars = $this->emptyGeneralVars(); + $vars['dex1'] = 1_300_000; + $vars['dex2'] = 1_300_000; + $aux = CentennialAllStarGrowthService::initialAux( + $this->singleDexTarget('inheritance', 0), + [] + ); + $aux['granted']['dex1'] = 1_000_000; + $general = $this->createStateGeneralMock($vars, $aux); + + self::assertSame( + 1_400.0, + InheritancePointManager::getInstance()->getDexInheritancePoint( + $general, + 1_000_000, + [1, 2, 3, 4, 5] + ) + ); + } + public function testNpcDexStopsAtFortyPercentOfHistoricalTarget(): void { $target = 900000; diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index df8d7fb9..36e57144 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -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, + ); } } diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php index 063468a6..1ab16c79 100644 --- a/tests/GameClockTest.php +++ b/tests/GameClockTest.php @@ -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, diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php new file mode 100644 index 00000000..5377a4a6 --- /dev/null +++ b/tests/GeneralPictureSchemaTest.php @@ -0,0 +1,137 @@ + $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); + } +} diff --git a/tests/ImageSyncClientTest.php b/tests/ImageSyncClientTest.php new file mode 100644 index 00000000..d1b185f7 --- /dev/null +++ b/tests/ImageSyncClientTest.php @@ -0,0 +1,35 @@ +expectException(InvalidArgumentException::class); + ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef'); + } +} diff --git a/tests/LoginByTokenKakaoFailureTest.php b/tests/LoginByTokenKakaoFailureTest.php new file mode 100644 index 00000000..793ddea0 --- /dev/null +++ b/tests/LoginByTokenKakaoFailureTest.php @@ -0,0 +1,36 @@ + false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => '인증 코드를 입력해주세요', + ], + LoginByToken::kakaoFailureResponse(true, '인증 코드를 입력해주세요') + ); + } + + public function testNonOtpKakaoFailureKeepsLegacyErrorAndDiscardsToken(): void + { + self::assertTrue(LoginByToken::shouldDiscardTokenAfterKakaoFailure(false)); + self::assertNull(LoginByToken::pendingSessionTokenID(false, 321)); + self::assertSame( + '카카오 API 앱이 등록되지 않았습니다.', + LoginByToken::kakaoFailureResponse(false, '카카오 API 앱이 등록되지 않았습니다.') + ); + } +} diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php new file mode 100644 index 00000000..6703f9bd --- /dev/null +++ b/tests/RemoteUserIconUploadClientTest.php @@ -0,0 +1,124 @@ +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)); + } +} diff --git a/tests/browser/auto-login-otp-dialog.mjs b/tests/browser/auto-login-otp-dialog.mjs new file mode 100644 index 00000000..8dd0b91a --- /dev/null +++ b/tests/browser/auto-login-otp-dialog.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const artifactDir = process.env.REF_AUTO_LOGIN_OTP_ARTIFACT_DIR; +if (!artifactDir) { + throw new Error('REF_AUTO_LOGIN_OTP_ARTIFACT_DIR is required'); +} + +const playwrightRequire = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); +let chromium; +try { + ({chromium} = playwrightRequire('playwright')); +} catch { + ({chromium} = playwrightRequire('@playwright/test')); +} + +const bundleDir = path.join(projectRoot, 'dist_js/gateway'); +const loginToken = [1, [321, 'browser-test-token'], Date.now()]; +let autoLoginCount = 0; +let manualLoginBody = null; +let otpBody = ''; +let otpAccepted = false; + +const loginHTML = ` + + + + + 자동로그인 OTP 검증 + + + + + + + +
+
+ + + + + + +
+ + 비밀번호 초기화 +
+ + +`; + +const json = (response, body) => { + response.writeHead(200, {'content-type': 'application/json; charset=utf-8'}); + response.end(JSON.stringify(body)); +}; + +const readBody = async request => { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const server = http.createServer(async (request, response) => { + const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestURL.pathname === '/') { + response.writeHead(200, {'content-type': 'text/html; charset=utf-8'}); + response.end(otpAccepted ? '

로그인 완료

' : loginHTML); + return; + } + if (requestURL.pathname.startsWith('/dist_js/gateway/')) { + const fileName = path.basename(requestURL.pathname); + const filePath = path.join(bundleDir, fileName); + const contentType = fileName.endsWith('.css') ? 'text/css' : 'text/javascript'; + response.writeHead(200, {'content-type': `${contentType}; charset=utf-8`}); + response.end(fs.readFileSync(filePath)); + return; + } + if (requestURL.pathname === '/api.php') { + const apiPath = requestURL.searchParams.get('path'); + const rawBody = await readBody(request); + if (apiPath === 'Login/ReqNonce') { + json(response, {result: true, loginNonce: `nonce-${autoLoginCount + 1}`}); + return; + } + if (apiPath === 'Login/LoginByToken') { + autoLoginCount += 1; + json(response, { + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }); + return; + } + if (apiPath === 'Login/LoginByID') { + manualLoginBody = JSON.parse(rawBody); + json(response, {result: false, reqOTP: false, reason: '수동 로그인 요청 확인'}); + return; + } + } + if (requestURL.pathname === '/oauth_kakao/j_check_OTP.php') { + otpBody = await readBody(request); + otpAccepted = true; + json(response, {result: true, reset: false, validUntil: '2026-08-18 00:00:00'}); + return; + } + response.writeHead(404); + response.end('not found'); +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +assert(address && typeof address !== 'string'); +const baseURL = `http://127.0.0.1:${address.port}/`; + +const browser = await chromium.launch({headless: true}); +try { + const context = await browser.newContext({ + viewport: {width: 1280, height: 960}, + deviceScaleFactor: 1, + locale: 'ko-KR', + }); + await context.addInitScript(token => { + if (!localStorage.getItem('sammo_login_token')) { + localStorage.setItem('sammo_login_token', JSON.stringify(token)); + } + }, loginToken); + const page = await context.newPage(); + + const openAndAcceptOtpPrompt = async navigation => { + const dialogPromise = page.waitForEvent('dialog'); + const navigationPromise = navigation(); + const dialog = await dialogPromise; + assert.equal(dialog.message(), '인증 코드 입력이 필요합니다.'); + await dialog.accept(); + await navigationPromise; + await page.locator('#modalOTP.show').waitFor({state: 'visible'}); + await page.waitForFunction(() => document.activeElement?.id === 'otp_code'); + }; + + await openAndAcceptOtpPrompt(() => page.goto(baseURL, {waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 1); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + fs.mkdirSync(artifactDir, {recursive: true}); + const screenshotPath = path.join(artifactDir, 'auto-login-otp-modal.png'); + await page.screenshot({path: screenshotPath, fullPage: true}); + const modalGeometry = await page.locator('#modalOTP .modal-dialog').evaluate(element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + display: style.display, + opacity: style.opacity, + }; + }); + + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 2); + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + await page.waitForTimeout(250); + + await page.locator('#username').fill('manual-user'); + await page.locator('#password').fill('manual-password'); + await page.waitForTimeout(100); + const manualDialogPromise = page.waitForEvent('dialog'); + await page.locator('#main_form').evaluate(form => form.requestSubmit()); + const manualDialog = await manualDialogPromise; + assert.equal(manualDialog.message(), '수동 로그인 요청 확인'); + await manualDialog.accept(); + assert.equal(manualLoginBody?.username, 'manual-user'); + assert.match(manualLoginBody?.password ?? '', /^[0-9a-f]{128}$/); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 3); + await page.locator('#otp_code').fill('1234'); + const successDialogPromise = page.waitForEvent('dialog'); + await page.getByRole('button', {name: '제출'}).click(); + const successDialog = await successDialogPromise; + assert.equal(successDialog.message(), '로그인되었습니다. 2026-08-18 00:00:00까지 유효합니다.'); + await successDialog.accept(); + await page.locator('#logged-in').waitFor({state: 'visible'}); + assert.match(otpBody, /name="otp"/); + assert.match(otpBody, /\r\n1234\r\n/); + + const resultPath = path.join(artifactDir, 'auto-login-otp-result.json'); + fs.writeFileSync(resultPath, `${JSON.stringify({ + url: baseURL, + viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, + autoLoginCount, + preservedTokenAfterCancel: true, + reopenedAfterReload: true, + manualLoginSubmitted: true, + otpSuccessNavigated: true, + modalGeometry, + screenshotPath, + }, null, 2)}\n`, {mode: 0o600}); + + console.log(`automatic-login OTP Chromium flow verified: ${resultPath}`); +} finally { + await browser.close(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +}