Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ad14c027 | ||
|
|
dc56ee1c06 | ||
|
|
463554654c | ||
|
|
5317cb306e | ||
|
|
db9c7ea828 | ||
|
|
f3bf490df3 | ||
|
|
eeb931d3f0 | ||
|
|
b095fb4bf1 | ||
|
|
234ec919bc | ||
|
|
a953ab4154 | ||
|
|
56a023e195 | ||
|
|
69fb122ab1 | ||
|
|
f9b5733fb8 | ||
|
|
66e2e28df2 |
@@ -42,7 +42,32 @@ sudo -u www-data git clone https://storage.hided.net/gitea/devsam/core.git
|
|||||||
sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git
|
sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git
|
||||||
```
|
```
|
||||||
|
|
||||||
> 이미지는 hook/git_hook.php을 통해 동기화되며, 서버 설치 과정에 이미지 갱신 키를 지정하는 것으로 '훼' 서버 업데이트 시 동기화됩니다. 이미지 서버가 게임 서버와 별개여도 동작하나, php와 git을 지원해야합니다.
|
> 이미지는 Gitea webhook을 기본으로 동기화합니다. Webhook 전달이 누락된 경우에도 서버 설치 과정에서 이미지 갱신 API와 `core` 전용 비밀값을 지정하면 게임 서버 업데이트 명령이 서명된 동기화를 요청합니다. 이미지 서버의 브랜치 변경 권한은 부여되지 않습니다.
|
||||||
|
|
||||||
|
CLI에서 수동으로 복구 동기화를 요청할 수도 있습니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \
|
||||||
|
php scripts/sync-image-repository.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Ref의 사용자 아이콘 원격 업로드 구현은 기본적으로 꺼져 있습니다. 이미지 서버의
|
||||||
|
`image_upload_core_secret`과 동일한 값을 Git 제외 파일
|
||||||
|
`d_setting/image_upload_core_secret`에 저장한 뒤 실제
|
||||||
|
`d_setting/ServConfig.php`에서 다음 값만 변경하면 기존 화면을 그대로 둔 채
|
||||||
|
원격 bind 저장소로 전환됩니다.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static $remoteUserIconUploadEnabled = true;
|
||||||
|
public static $remoteUserIconUploadPath = 'https://sam-image.hided.net';
|
||||||
|
public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_secret';
|
||||||
|
```
|
||||||
|
|
||||||
|
플래그가 `false`이면 기존 `d_pic` 및 `IMGSVR=1` 동작을 유지합니다. `true`이면
|
||||||
|
PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로
|
||||||
|
이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다.
|
||||||
|
국방·외교 등 TipTap 편집기 첨부 이미지도 같은 플래그로 `/uploads/core/` bind
|
||||||
|
저장소로 전환됩니다. 공유 비밀값은 브라우저나 Cloudflare로 보내지 않습니다.
|
||||||
|
|
||||||
### 설치
|
### 설치
|
||||||
|
|
||||||
@@ -67,4 +92,4 @@ Database 수는 로그인 관리 서버 1개, 내부 서버 7개로, 총 8개의
|
|||||||
* MIT License
|
* MIT License
|
||||||
* GPL 2.0 또는 이후
|
* GPL 2.0 또는 이후
|
||||||
|
|
||||||
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
|
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
|
||||||
|
|||||||
+11
-4
@@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
|
|||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
|
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/icons" />
|
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/icons" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
|
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/game" />
|
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/game" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="image_request_path" class="col-sm-4 col-form-label">이미지 갱신 API</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="url" class="form-control" name="image_request_path" id="image_request_path" value="https://sam-image.hided.net/v1/sync" required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
|
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
|
||||||
<div class="input-group col-sm-8">
|
<div class="input-group col-sm-8">
|
||||||
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 hook/HashKey.php의 값과 동일하게" value="" />
|
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 core 동기화 비밀값과 동일하게" value="" />
|
||||||
<div class="input-group-text">
|
<div class="input-group-text">
|
||||||
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
|
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name');
|
|||||||
$servHost = Util::getPost('serv_host');
|
$servHost = Util::getPost('serv_host');
|
||||||
$sharedIconPath = Util::getPost('shared_icon_path');
|
$sharedIconPath = Util::getPost('shared_icon_path');
|
||||||
$gameImagePath = Util::getPost('game_image_path');
|
$gameImagePath = Util::getPost('game_image_path');
|
||||||
|
$imageRequestPath = Util::getPost('image_request_path');
|
||||||
$imageRequestKey = Util::getPost('image_request_key');
|
$imageRequestKey = Util::getPost('image_request_key');
|
||||||
|
|
||||||
$kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', '');
|
$kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', '');
|
||||||
$kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', '');
|
$kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', '');
|
||||||
|
|
||||||
|
|
||||||
if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath) {
|
if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath || !$imageRequestPath) {
|
||||||
Json::die([
|
Json::die([
|
||||||
'result' => false,
|
'result' => false,
|
||||||
'reason' => '입력 값이 올바르지 않습니다'
|
'reason' => '입력 값이 올바르지 않습니다'
|
||||||
@@ -32,6 +33,21 @@ if (!filter_var($servHost, FILTER_VALIDATE_URL)) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!filter_var($imageRequestPath, FILTER_VALIDATE_URL)
|
||||||
|
|| parse_url($imageRequestPath, PHP_URL_SCHEME) !== 'https') {
|
||||||
|
Json::die([
|
||||||
|
'result' => false,
|
||||||
|
'reason' => '이미지 갱신 API는 HTTPS URL이어야 합니다.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($imageRequestKey !== null && $imageRequestKey !== '' && strlen($imageRequestKey) < 32) {
|
||||||
|
Json::die([
|
||||||
|
'result' => false,
|
||||||
|
'reason' => '이미지 동기화 비밀값은 32자 이상이어야 합니다.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) {
|
if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) {
|
||||||
Json::die([
|
Json::die([
|
||||||
'result' => false,
|
'result' => false,
|
||||||
@@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16));
|
|||||||
|
|
||||||
$sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost);
|
$sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost);
|
||||||
$gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost);
|
$gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost);
|
||||||
$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost);
|
$imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost);
|
||||||
$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost);
|
|
||||||
|
|
||||||
$result = Util::generateFileUsingSimpleTemplate(
|
$result = Util::generateFileUsingSimpleTemplate(
|
||||||
__DIR__ . '/templates/ServConfig.orig.php',
|
__DIR__ . '/templates/ServConfig.orig.php',
|
||||||
@@ -197,6 +212,9 @@ $result = Util::generateFileUsingSimpleTemplate(
|
|||||||
'gameImagePath' => $gameImagePath,
|
'gameImagePath' => $gameImagePath,
|
||||||
'imageRequestPath' => $imageRequestPath,
|
'imageRequestPath' => $imageRequestPath,
|
||||||
'imageRequestKey' => $imageRequestKey,
|
'imageRequestKey' => $imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => 'false',
|
||||||
|
'remoteUserIconUploadPath' => 'https://sam-image.hided.net',
|
||||||
|
'remoteUserIconUploadSecretFile' => 'd_setting/image_upload_core_secret',
|
||||||
'serverList' => [
|
'serverList' => [
|
||||||
['che', '체', 'white'],
|
['che', '체', 'white'],
|
||||||
['kwe', '퀘', 'yellow'],
|
['kwe', '퀘', 'yellow'],
|
||||||
@@ -209,10 +227,6 @@ $result = Util::generateFileUsingSimpleTemplate(
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($imageRequestKey) {
|
|
||||||
@file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result !== true) {
|
if ($result !== true) {
|
||||||
Json::die([
|
Json::die([
|
||||||
'result' => false,
|
'result' => false,
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ if ($servHost) {
|
|||||||
[
|
[
|
||||||
'serverBasePath' => $servHost,
|
'serverBasePath' => $servHost,
|
||||||
'sharedIconPath' => $sharedIconPath,
|
'sharedIconPath' => $sharedIconPath,
|
||||||
'gameImagePath' => $gameImagePath
|
'gameImagePath' => $gameImagePath,
|
||||||
|
'imageRequestPath' => ServConfig::$imageRequestPath,
|
||||||
|
'imageRequestKey' => ServConfig::$imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
|
||||||
|
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
|
||||||
|
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
|
||||||
],
|
],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -64,7 +69,12 @@ if ($servHost) {
|
|||||||
[
|
[
|
||||||
'serverBasePath' => $servHost,
|
'serverBasePath' => $servHost,
|
||||||
'sharedIconPath' => $sharedIconPath,
|
'sharedIconPath' => $sharedIconPath,
|
||||||
'gameImagePath' => $gameImagePath
|
'gameImagePath' => $gameImagePath,
|
||||||
|
'imageRequestPath' => ServConfig::$imageRequestPath,
|
||||||
|
'imageRequestKey' => ServConfig::$imageRequestKey,
|
||||||
|
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
|
||||||
|
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
|
||||||
|
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
|
||||||
],
|
],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class ServConfig
|
|||||||
public static $gameImagePath = "_tK_gameImagePath_";
|
public static $gameImagePath = "_tK_gameImagePath_";
|
||||||
public static $imageRequestPath = "_tK_imageRequestPath_";
|
public static $imageRequestPath = "_tK_imageRequestPath_";
|
||||||
public static $imageRequestKey = '_tK_imageRequestKey_';
|
public static $imageRequestKey = '_tK_imageRequestKey_';
|
||||||
|
public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_;
|
||||||
|
public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_';
|
||||||
|
public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_';
|
||||||
private static $serverList = null;
|
private static $serverList = null;
|
||||||
|
|
||||||
public static function getSharedIconPath(string $filepath = ''): string
|
public static function getSharedIconPath(string $filepath = ''): string
|
||||||
@@ -38,9 +41,33 @@ class ServConfig
|
|||||||
|
|
||||||
public static function getImagePullURI(): string
|
public static function getImagePullURI(): string
|
||||||
{
|
{
|
||||||
$now = time();
|
return static::$imageRequestPath;
|
||||||
$req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey);
|
}
|
||||||
return static::$imageRequestPath . "?req={$req_hash}&time={$now}";
|
|
||||||
|
public static function isRemoteUserIconUploadEnabled(): bool
|
||||||
|
{
|
||||||
|
return static::$remoteUserIconUploadEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRemoteUserIconUploadURI(string $filename): string
|
||||||
|
{
|
||||||
|
return rtrim(static::$remoteUserIconUploadPath, '/') . '/v1/uploads/user-icons/core/' . $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRemoteUserIconUploadSecret(): string
|
||||||
|
{
|
||||||
|
$path = static::$remoteUserIconUploadSecretFile;
|
||||||
|
if ($path === '' || str_contains($path, "\0")) {
|
||||||
|
throw new \RuntimeException('Remote user icon upload secret file is not configured');
|
||||||
|
}
|
||||||
|
if ($path[0] !== '/') {
|
||||||
|
$path = ROOT . '/' . $path;
|
||||||
|
}
|
||||||
|
$secret = trim((string)file_get_contents($path));
|
||||||
|
if (strlen($secret) < 32) {
|
||||||
|
throw new \RuntimeException('Remote user icon upload secret must be at least 32 characters');
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1734,7 +1734,7 @@ function deleteNation(General $lord, bool $applyDB): array
|
|||||||
|
|
||||||
$nationGeneralList = General::createObjListFromDB(
|
$nationGeneralList = General::createObjListFromDB(
|
||||||
$db->queryFirstColumn(
|
$db->queryFirstColumn(
|
||||||
'SELECT `no` FROM general WHERE nation=%i AND no != %i',
|
'SELECT `no` FROM general WHERE nation=%i AND no != %i ORDER BY no ASC',
|
||||||
$nationID,
|
$nationID,
|
||||||
$lordID
|
$lordID
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ function checkEmperior()
|
|||||||
|
|
||||||
$chiefs = Util::convertArrayToDict(
|
$chiefs = Util::convertArrayToDict(
|
||||||
$db->query(
|
$db->query(
|
||||||
'SELECT no,npc,name,picture,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
|
'SELECT no,npc,name,picture,imgsvr,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
|
||||||
$nationID
|
$nationID
|
||||||
),
|
),
|
||||||
'officer_level'
|
'officer_level'
|
||||||
@@ -893,20 +893,28 @@ function checkEmperior()
|
|||||||
'rice' => $nation['rice'],
|
'rice' => $nation['rice'],
|
||||||
'l12name' => $chiefs[12]['name'],
|
'l12name' => $chiefs[12]['name'],
|
||||||
'l12pic' => $chiefs[12]['picture'],
|
'l12pic' => $chiefs[12]['picture'],
|
||||||
|
'l12imgsvr' => $chiefs[12]['imgsvr'],
|
||||||
'l11name' => $chiefs[11]['name'],
|
'l11name' => $chiefs[11]['name'],
|
||||||
'l11pic' => $chiefs[11]['picture'],
|
'l11pic' => $chiefs[11]['picture'],
|
||||||
|
'l11imgsvr' => $chiefs[11]['imgsvr'],
|
||||||
'l10name' => $chiefs[10]['name'],
|
'l10name' => $chiefs[10]['name'],
|
||||||
'l10pic' => $chiefs[10]['picture'],
|
'l10pic' => $chiefs[10]['picture'],
|
||||||
|
'l10imgsvr' => $chiefs[10]['imgsvr'],
|
||||||
'l9name' => $chiefs[9]['name'],
|
'l9name' => $chiefs[9]['name'],
|
||||||
'l9pic' => $chiefs[9]['picture'],
|
'l9pic' => $chiefs[9]['picture'],
|
||||||
|
'l9imgsvr' => $chiefs[9]['imgsvr'],
|
||||||
'l8name' => $chiefs[8]['name'],
|
'l8name' => $chiefs[8]['name'],
|
||||||
'l8pic' => $chiefs[8]['picture'],
|
'l8pic' => $chiefs[8]['picture'],
|
||||||
|
'l8imgsvr' => $chiefs[8]['imgsvr'],
|
||||||
'l7name' => $chiefs[7]['name'],
|
'l7name' => $chiefs[7]['name'],
|
||||||
'l7pic' => $chiefs[7]['picture'],
|
'l7pic' => $chiefs[7]['picture'],
|
||||||
|
'l7imgsvr' => $chiefs[7]['imgsvr'],
|
||||||
'l6name' => $chiefs[6]['name'],
|
'l6name' => $chiefs[6]['name'],
|
||||||
'l6pic' => $chiefs[6]['picture'],
|
'l6pic' => $chiefs[6]['picture'],
|
||||||
|
'l6imgsvr' => $chiefs[6]['imgsvr'],
|
||||||
'l5name' => $chiefs[5]['name'],
|
'l5name' => $chiefs[5]['name'],
|
||||||
'l5pic' => $chiefs[5]['picture'],
|
'l5pic' => $chiefs[5]['picture'],
|
||||||
|
'l5imgsvr' => $chiefs[5]['imgsvr'],
|
||||||
'tiger' => $tigerstr,
|
'tiger' => $tigerstr,
|
||||||
'eagle' => $eaglestr,
|
'eagle' => $eaglestr,
|
||||||
'gen' => $gen,
|
'gen' => $gen,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){
|
|||||||
|
|
||||||
$db = DB::db();
|
$db = DB::db();
|
||||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||||
$clock = GameClock::fromStorage($gameStor);
|
|
||||||
|
|
||||||
if(file_exists(__DIR__.'/.htaccess')){
|
if(file_exists(__DIR__.'/.htaccess')){
|
||||||
$reserved = $db->queryFirstRow(
|
$reserved = $db->queryFirstRow(
|
||||||
@@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){
|
|||||||
|
|
||||||
//TODO: 천통시에도 예약 오픈 알림이 필요..?
|
//TODO: 천통시에도 예약 오픈 알림이 필요..?
|
||||||
|
|
||||||
|
$usesLogicalClock = GameClock::isInitialized($gameStor);
|
||||||
$admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']);
|
$admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']);
|
||||||
$admin['maxUserCnt'] = $admin['maxgeneral'];
|
$admin['maxUserCnt'] = $admin['maxgeneral'];
|
||||||
$admin['npcMode'] = $admin['npcmode'];
|
$admin['npcMode'] = $admin['npcmode'];
|
||||||
$admin['turnTerm'] = $admin['turnterm'];
|
$admin['turnTerm'] = $admin['turnterm'];
|
||||||
$admin['isUnited'] = $admin['isunited'];
|
$admin['isUnited'] = $admin['isunited'];
|
||||||
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
|
if($usesLogicalClock){
|
||||||
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
|
$clock = GameClock::fromStorage($gameStor);
|
||||||
$admin['starttime'] = substr($admin['opentime'], 5, 11);
|
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
|
||||||
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
|
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
|
||||||
|
$admin['starttime'] = substr($admin['opentime'], 5, 11);
|
||||||
|
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$admin['isOpen'] = new \DateTimeImmutable((string)$admin['opentime']) <= GameClock::readWallTime();
|
||||||
|
$admin['starttime'] = substr((string)$admin['opentime'], 5, 11);
|
||||||
|
$admin['turntime'] = substr((string)$admin['turntime'], 5, 11);
|
||||||
|
}
|
||||||
unset($admin['npcmode']);
|
unset($admin['npcmode']);
|
||||||
unset($admin['maxgeneral']);
|
unset($admin['maxgeneral']);
|
||||||
unset($admin['turnterm']);
|
unset($admin['turnterm']);
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
|
|||||||
|
|
||||||
$city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
|
$city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
|
||||||
|
|
||||||
$defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0', $city->getVar('nation'), $city->getVar('city'));
|
$defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0 ORDER BY no', $city->getVar('nation'), $city->getVar('city'));
|
||||||
$defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null);
|
$defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null);
|
||||||
|
|
||||||
/** @var WarUnit[] */
|
/** @var WarUnit[] */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use sammo\AppConf;
|
|||||||
use sammo\Enums\APIRecoveryType;
|
use sammo\Enums\APIRecoveryType;
|
||||||
use sammo\KVStorage;
|
use sammo\KVStorage;
|
||||||
use sammo\RootDB;
|
use sammo\RootDB;
|
||||||
|
use sammo\RemoteUserIconUploadClient;
|
||||||
use sammo\TimeUtil;
|
use sammo\TimeUtil;
|
||||||
use sammo\UniqueConst;
|
use sammo\UniqueConst;
|
||||||
use sammo\Validator;
|
use sammo\Validator;
|
||||||
@@ -60,22 +61,33 @@ class UploadImage extends \sammo\BaseAPI
|
|||||||
$imgName = hash_final($oMD);
|
$imgName = hash_final($oMD);
|
||||||
$imgFullName = "{$imgName}.{$extension}";
|
$imgFullName = "{$imgName}.{$extension}";
|
||||||
|
|
||||||
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
|
$remotePath = null;
|
||||||
$destPath = "{$destDir}/{$imgFullName}";
|
if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
|
||||||
|
try {
|
||||||
|
RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData);
|
||||||
|
$remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
RemoteUserIconUploadClient::logFailure('content-image', $error);
|
||||||
|
return '원격 이미지 저장소 업로드에 실패했습니다!';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
|
||||||
|
$destPath = "{$destDir}/{$imgFullName}";
|
||||||
|
|
||||||
if (!file_exists($destPath)) {
|
if (!file_exists($destPath)) {
|
||||||
if (!file_exists($destDir)) {
|
if (!file_exists($destDir)) {
|
||||||
mkdir($destDir);
|
mkdir($destDir);
|
||||||
}
|
}
|
||||||
if (!is_dir($destDir)) {
|
if (!is_dir($destDir)) {
|
||||||
return '버그! 업로드 경로 확인!';
|
return '버그! 업로드 경로 확인!';
|
||||||
}
|
}
|
||||||
if (!is_writable($destDir)) {
|
if (!is_writable($destDir)) {
|
||||||
return '버그! 업로드 권한 확인!';
|
return '버그! 업로드 권한 확인!';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_put_contents($destPath, $imageData)) {
|
if (!file_put_contents($destPath, $imageData)) {
|
||||||
return '업로드에 실패했습니다!';
|
return '업로드에 실패했습니다!';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'result' => true,
|
'result' => true,
|
||||||
'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
|
'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI
|
|||||||
|
|
||||||
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
|
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
|
||||||
$env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']);
|
$env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']);
|
||||||
|
$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;
|
||||||
|
$formatStoredTime = static fn (mixed $value): string => $clock === null
|
||||||
|
? (string)$value
|
||||||
|
: $clock->formatTick(Util::toInt($value));
|
||||||
|
$env['turntime'] = $formatStoredTime($env['turntime']);
|
||||||
|
|
||||||
$me = $db->queryFirstRow(
|
$me = $db->queryFirstRow(
|
||||||
'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general`
|
'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general`
|
||||||
@@ -187,7 +192,7 @@ class GeneralList extends \sammo\BaseAPI
|
|||||||
if (!key_exists($troopLeaderID, $rawGeneralList)) {
|
if (!key_exists($troopLeaderID, $rawGeneralList)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime'];
|
$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']);
|
||||||
$troops[$troopLeaderID] = new ArrayObject([
|
$troops[$troopLeaderID] = new ArrayObject([
|
||||||
'id' => $troopLeaderID,
|
'id' => $troopLeaderID,
|
||||||
'name' => $troopName,
|
'name' => $troopName,
|
||||||
@@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI
|
|||||||
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
|
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
|
||||||
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
|
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
|
||||||
//'0000-00-00 11:23';
|
//'0000-00-00 11:23';
|
||||||
'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor)
|
'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19),
|
||||||
->formatTick(Util::toInt($rawGeneral['turntime'])),
|
|
||||||
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
|
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
|
||||||
? null
|
? null
|
||||||
: GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])),
|
: substr($formatStoredTime($rawGeneral['recent_war']), 0, 19),
|
||||||
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
|
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
|
||||||
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
|
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
|
||||||
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
|
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
|
|||||||
$month,
|
$month,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s', GameConst::$defaultSpecialDomestic) as $general) {
|
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s ORDER BY no ASC', GameConst::$defaultSpecialDomestic) as $general) {
|
||||||
$generalID = $general['no'];
|
$generalID = $general['no'];
|
||||||
$special = SpecialityHelper::pickSpecialDomestic(
|
$special = SpecialityHelper::pickSpecialDomestic(
|
||||||
$rng,
|
$rng,
|
||||||
@@ -57,7 +57,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
|
|||||||
$logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득");
|
$logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s', GameConst::$defaultSpecialWar) as $general) {
|
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s ORDER BY no ASC', GameConst::$defaultSpecialWar) as $general) {
|
||||||
$generalID = $general['no'];
|
$generalID = $general['no'];
|
||||||
$generalAux = Json::decode($general['aux']);
|
$generalAux = Json::decode($general['aux']);
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -11,7 +11,7 @@ CREATE TABLE `general` (
|
|||||||
`bornyear` INT(3) NULL DEFAULT '180',
|
`bornyear` INT(3) NULL DEFAULT '180',
|
||||||
`deadyear` INT(3) NULL DEFAULT '300',
|
`deadyear` INT(3) NULL DEFAULT '300',
|
||||||
`newmsg` INT(1) NULL DEFAULT '0',
|
`newmsg` INT(1) NULL DEFAULT '0',
|
||||||
`picture` VARCHAR(40) NOT NULL,
|
`picture` VARCHAR(64) NOT NULL,
|
||||||
`imgsvr` INT(1) NOT NULL DEFAULT '0',
|
`imgsvr` INT(1) NOT NULL DEFAULT '0',
|
||||||
`name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin',
|
`name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin',
|
||||||
`owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin',
|
`owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin',
|
||||||
@@ -346,21 +346,29 @@ CREATE TABLE IF NOT EXISTS `emperior` (
|
|||||||
`gold` INT(9) NULL DEFAULT '0',
|
`gold` INT(9) NULL DEFAULT '0',
|
||||||
`rice` INT(9) NULL DEFAULT '0',
|
`rice` INT(9) NULL DEFAULT '0',
|
||||||
`l12name` VARCHAR(64) NULL DEFAULT '',
|
`l12name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l12pic` VARCHAR(32) NULL DEFAULT '',
|
`l12pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l12imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l11name` VARCHAR(64) NULL DEFAULT '',
|
`l11name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l11pic` VARCHAR(32) NULL DEFAULT '',
|
`l11pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l11imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l10name` VARCHAR(64) NULL DEFAULT '',
|
`l10name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l10pic` VARCHAR(32) NULL DEFAULT '',
|
`l10pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l10imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l9name` VARCHAR(64) NULL DEFAULT '',
|
`l9name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l9pic` VARCHAR(32) NULL DEFAULT '',
|
`l9pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l9imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l8name` VARCHAR(64) NULL DEFAULT '',
|
`l8name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l8pic` VARCHAR(32) NULL DEFAULT '',
|
`l8pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l8imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l7name` VARCHAR(64) NULL DEFAULT '',
|
`l7name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l7pic` VARCHAR(32) NULL DEFAULT '',
|
`l7pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l7imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l6name` VARCHAR(64) NULL DEFAULT '',
|
`l6name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l6pic` VARCHAR(32) NULL DEFAULT '',
|
`l6pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l6imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`l5name` VARCHAR(64) NULL DEFAULT '',
|
`l5name` VARCHAR(64) NULL DEFAULT '',
|
||||||
`l5pic` VARCHAR(32) NULL DEFAULT '',
|
`l5pic` VARCHAR(64) NULL DEFAULT '',
|
||||||
|
`l5imgsvr` INT(1) NULL DEFAULT NULL,
|
||||||
`tiger` VARCHAR(128) NULL DEFAULT '',
|
`tiger` VARCHAR(128) NULL DEFAULT '',
|
||||||
`eagle` VARCHAR(128) NULL DEFAULT '',
|
`eagle` VARCHAR(128) NULL DEFAULT '',
|
||||||
`gen` TEXT NULL DEFAULT '',
|
`gen` TEXT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -69,11 +69,8 @@ function setupDBForm() {
|
|||||||
|
|
||||||
$('#btn_random_generate_key').on('click', function (e) {
|
$('#btn_random_generate_key').on('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let token = '';
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
while (token.length < 24) {
|
const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
token += (Math.random() + 1).toString(36).substring(7);
|
|
||||||
}
|
|
||||||
token = token.substr(0, 24);
|
|
||||||
$('#image_request_key').val(token);
|
$('#image_request_key').val(token);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +83,7 @@ function setupDBForm() {
|
|||||||
serv_host: string,
|
serv_host: string,
|
||||||
shared_icon_path: string,
|
shared_icon_path: string,
|
||||||
game_image_path: string,
|
game_image_path: string,
|
||||||
|
image_request_path: string,
|
||||||
image_request_key: string,
|
image_request_key: string,
|
||||||
kakao_rest_key: string,
|
kakao_rest_key: string,
|
||||||
kakao_admin_key: string,
|
kakao_admin_key: string,
|
||||||
@@ -124,10 +122,14 @@ function setupDBForm() {
|
|||||||
required: true,
|
required: true,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
image_request_path: {
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
image_request_key: {
|
image_request_key: {
|
||||||
required: false,
|
required: false,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
min: 16,
|
min: 32,
|
||||||
},
|
},
|
||||||
kakao_rest_key: {
|
kakao_rest_key: {
|
||||||
required: false,
|
required: false,
|
||||||
@@ -163,6 +165,7 @@ function setupDBForm() {
|
|||||||
serv_host: values.serv_host,
|
serv_host: values.serv_host,
|
||||||
shared_icon_path: values.shared_icon_path,
|
shared_icon_path: values.shared_icon_path,
|
||||||
game_image_path: values.game_image_path,
|
game_image_path: values.game_image_path,
|
||||||
|
image_request_path: values.image_request_path,
|
||||||
image_request_key: values.image_request_key,
|
image_request_key: values.image_request_key,
|
||||||
kakao_rest_key: values.kakao_rest_key,
|
kakao_rest_key: values.kakao_rest_key,
|
||||||
kakao_admin_key: values.kakao_admin_key,
|
kakao_admin_key: values.kakao_admin_key,
|
||||||
@@ -313,4 +316,4 @@ $(function () {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!move_uploaded_file($image['tmp_name'], $dest)) {
|
if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
|
||||||
|
try {
|
||||||
|
$remoteName = bin2hex(random_bytes(16)).$newExt;
|
||||||
|
$contentType = image_type_to_mime_type($imageType);
|
||||||
|
RemoteUserIconUploadClient::uploadConfigured(
|
||||||
|
$remoteName,
|
||||||
|
$contentType,
|
||||||
|
(string)file_get_contents($image['tmp_name'])
|
||||||
|
);
|
||||||
|
$newPicName = "users/core/{$remoteName}";
|
||||||
|
$storedRemotely = true;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
RemoteUserIconUploadClient::logFailure('user-icon', $error);
|
||||||
|
$storedRemotely = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$storedRemotely = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($storedRemotely === false) {
|
||||||
|
$response['reason'] = '원격 이미지 저장소 업로드에 실패했습니다!';
|
||||||
|
$response['result'] = false;
|
||||||
|
} elseif($storedRemotely === null && !move_uploaded_file($image['tmp_name'], $dest)) {
|
||||||
$response['reason'] = '업로드에 실패했습니다!';
|
$response['reason'] = '업로드에 실패했습니다!';
|
||||||
$response['result'] = false;
|
$response['result'] = false;
|
||||||
} else {
|
} else {
|
||||||
$pic = "{$newPicName}?={$rf}";
|
$pic = "{$newPicName}?={$rf}";
|
||||||
RootDB::db()->update('member',[
|
RootDB::db()->update('member',[
|
||||||
'PICTURE' => $pic,
|
'PICTURE' => $pic,
|
||||||
'IMGSVR' => 1
|
'IMGSVR' => $storedRemotely === true ? 0 : 1
|
||||||
], 'NO=%i', $userID);
|
], 'NO=%i', $userID);
|
||||||
|
|
||||||
$servers = [];
|
$servers = [];
|
||||||
@@ -104,4 +126,4 @@ if(!is_uploaded_file($image['tmp_name'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Json::die($response);
|
Json::die($response);
|
||||||
|
|||||||
+12
-14
@@ -335,20 +335,18 @@ if ($server == $baseServerName) {
|
|||||||
|
|
||||||
if (ServConfig::$imageRequestKey) {
|
if (ServConfig::$imageRequestKey) {
|
||||||
try {
|
try {
|
||||||
$imagePullPath = ServConfig::getImagePullURI();
|
$configuredPath = ServConfig::$imageRequestPath;
|
||||||
$pullResult = @file_get_contents($imagePullPath);
|
$legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php');
|
||||||
if ($pullResult === false) {
|
$imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL')
|
||||||
throw new \ErrorException('Invalid URI');
|
?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath);
|
||||||
}
|
$pullResult = ImageSyncClient::sync(
|
||||||
$pullResult = Json::decode($pullResult);
|
$imageSyncPath,
|
||||||
if ($pullResult['result']) {
|
'core',
|
||||||
$imgResult = true;
|
ServConfig::$imageRequestKey
|
||||||
$imgDetail = $pullResult['version'];
|
);
|
||||||
} else {
|
$imgResult = true;
|
||||||
$imgResult = false;
|
$imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current');
|
||||||
$imgDetail = $pullResult['reason'];
|
} catch (\Throwable $e) {
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$imgResult = false;
|
$imgResult = false;
|
||||||
$imgDetail = $e->getMessage();
|
$imgDetail = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
Executable
+345
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use sammo\DB;
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
pictureMigrationUsage();
|
||||||
|
}
|
||||||
|
if (isset($options['status']) === isset($options['apply'])) {
|
||||||
|
pictureMigrationUsage(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $options['server'] ?? null;
|
||||||
|
if (!is_string($server) || preg_match('/^[a-z][a-z0-9_-]*$/', $server) !== 1) {
|
||||||
|
fwrite(STDERR, "--server must name one game-server directory, for example che, kwe, or hwe.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__);
|
||||||
|
$serverDirectory = $projectRoot . '/' . $server;
|
||||||
|
foreach (['lib.php', 'func.php', 'd_setting/DB.php'] as $requiredFile) {
|
||||||
|
if (!is_file($serverDirectory . '/' . $requiredFile)) {
|
||||||
|
fwrite(STDERR, "Server directory '$server' is not a configured game server: missing $requiredFile.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
|
||||||
|
$_SERVER['REQUEST_URI'] ??= "/cli/migrate-general-picture/$server";
|
||||||
|
|
||||||
|
require $serverDirectory . '/lib.php';
|
||||||
|
require $serverDirectory . '/func.php';
|
||||||
|
|
||||||
|
/** @return never */
|
||||||
|
function pictureMigrationUsage(int $exitCode = 0): void
|
||||||
|
{
|
||||||
|
$stream = $exitCode === 0 ? STDOUT : STDERR;
|
||||||
|
fwrite($stream, <<<'TEXT'
|
||||||
|
Usage:
|
||||||
|
php scripts/migrate-general-picture.php --server=PREFIX --status
|
||||||
|
php scripts/migrate-general-picture.php --server=PREFIX --apply --backup=/absolute/path/to/pre-migration.sql
|
||||||
|
php scripts/migrate-general-picture.php --server=PREFIX --apply --server-closed --backup=/absolute/path/to/pre-migration.sql
|
||||||
|
|
||||||
|
PREFIX is one configured game directory such as che, kwe, or hwe. Run status,
|
||||||
|
backup, apply, and verification separately for every game database; this script
|
||||||
|
never loops over all servers implicitly.
|
||||||
|
|
||||||
|
--status is read-only. --apply widens general.picture and the eight emperior
|
||||||
|
chief picture columns to VARCHAR(64), adds nullable l12imgsvr through l5imgsvr,
|
||||||
|
and backfills only uniquely matched historical values from ng_old_generals.
|
||||||
|
It requires a pre-existing, non-empty SQL backup whenever a schema or data
|
||||||
|
change is needed. Stop web and daemon traffic before applying; MariaDB/Aria DDL
|
||||||
|
is not transactional. Normally the script acquires and releases the GAME lock.
|
||||||
|
Use --server-closed only after independently stopping web and daemon traffic;
|
||||||
|
that flag skips the GAME lock without changing its existing state. Unmatched or
|
||||||
|
ambiguous historical values remain NULL.
|
||||||
|
|
||||||
|
TEXT);
|
||||||
|
exit($exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
function emperiorPictureLevels(): array
|
||||||
|
{
|
||||||
|
return [12, 11, 10, 9, 8, 7, 6, 5];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{string, string, int}> */
|
||||||
|
function pictureMigrationColumns(): array
|
||||||
|
{
|
||||||
|
$columns = [['general', 'picture', 40]];
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
$columns[] = ['emperior', "l{$level}pic", 32];
|
||||||
|
}
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
function migrationColumnInfo(\MeekroDB $db, string $table, string $field): ?array
|
||||||
|
{
|
||||||
|
$column = $db->queryFirstRow("SHOW COLUMNS FROM `$table` WHERE Field = %s", $field);
|
||||||
|
return is_array($column) ? $column : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureColumnCapacity(\MeekroDB $db, string $table, string $field): ?int
|
||||||
|
{
|
||||||
|
$column = migrationColumnInfo($db, $table, $field);
|
||||||
|
if (!$column || !is_string($column['Type'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('/^varchar\((\d+)\)$/i', $column['Type'], $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (int)$matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgsvrColumnIsCompatible(\MeekroDB $db, int $level): bool
|
||||||
|
{
|
||||||
|
$column = migrationColumnInfo($db, 'emperior', "l{$level}imgsvr");
|
||||||
|
if (!$column || !is_string($column['Type'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return preg_match('/^(?:tinyint|smallint|mediumint|int|bigint)\(\d+\)(?: unsigned)?$/i', $column['Type']) === 1
|
||||||
|
&& strtoupper((string)($column['Null'] ?? '')) === 'YES';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureMigrationState(\MeekroDB $db): string
|
||||||
|
{
|
||||||
|
$needsMigration = false;
|
||||||
|
foreach (pictureMigrationColumns() as [$table, $field, $legacyCapacity]) {
|
||||||
|
$capacity = pictureColumnCapacity($db, $table, $field);
|
||||||
|
if ($capacity === $legacyCapacity) {
|
||||||
|
$needsMigration = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($capacity === null || $capacity < 64) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
|
||||||
|
$needsMigration = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!imgsvrColumnIsCompatible($db, $level)) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $needsMigration ? 'legacy' : 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgsvrColumnsExist(\MeekroDB $db): bool
|
||||||
|
{
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unresolvedEmperiorImgsvrCount(\MeekroDB $db): ?int
|
||||||
|
{
|
||||||
|
if (!imgsvrColumnsExist($db)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$terms = array_map(
|
||||||
|
static fn(int $level): string => "(`l{$level}imgsvr` IS NULL)",
|
||||||
|
emperiorPictureLevels(),
|
||||||
|
);
|
||||||
|
$count = $db->queryFirstField('SELECT SUM(' . implode(' + ', $terms) . ') FROM emperior');
|
||||||
|
return $count === null ? 0 : (int)$count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{no: int|string, imgsvr: int|string}> */
|
||||||
|
function recoverableEmperiorImgsvrRows(\MeekroDB $db, int $level): array
|
||||||
|
{
|
||||||
|
$nameField = "l{$level}name";
|
||||||
|
$pictureField = "l{$level}pic";
|
||||||
|
$imgsvrField = "l{$level}imgsvr";
|
||||||
|
return $db->query(
|
||||||
|
"SELECT e.`no`, MIN(CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.imgsvr')), '-1') AS SIGNED)) AS `imgsvr`
|
||||||
|
FROM `emperior` e
|
||||||
|
JOIN `ng_old_generals` og
|
||||||
|
ON og.`server_id` = e.`server_id`
|
||||||
|
AND og.`name` = e.`$nameField`
|
||||||
|
AND SUBSTRING_INDEX(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.picture')), ''), '?=', 1)
|
||||||
|
= SUBSTRING_INDEX(COALESCE(e.`$pictureField`, ''), '?=', 1)
|
||||||
|
AND CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.officer_level')), '-1') AS SIGNED) = %i
|
||||||
|
WHERE e.`$imgsvrField` IS NULL
|
||||||
|
GROUP BY e.`no`
|
||||||
|
HAVING COUNT(*) = 1 AND `imgsvr` IN (0, 1)",
|
||||||
|
$level,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverableEmperiorImgsvrCount(\MeekroDB $db): int
|
||||||
|
{
|
||||||
|
if (!imgsvrColumnsExist($db)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$count = 0;
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
$count += count(recoverableEmperiorImgsvrRows($db, $level));
|
||||||
|
}
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backfillEmperiorImgsvr(\MeekroDB $db): int
|
||||||
|
{
|
||||||
|
$updated = 0;
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
$field = "l{$level}imgsvr";
|
||||||
|
foreach (recoverableEmperiorImgsvrRows($db, $level) as $row) {
|
||||||
|
$db->update(
|
||||||
|
'emperior',
|
||||||
|
[$field => (int)$row['imgsvr']],
|
||||||
|
"`no`=%i AND `$field` IS NULL",
|
||||||
|
(int)$row['no'],
|
||||||
|
);
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printPictureMigrationStatus(\MeekroDB $db, string $server): string
|
||||||
|
{
|
||||||
|
$state = pictureMigrationState($db);
|
||||||
|
printf("server=%s\n", $server);
|
||||||
|
printf("schema_state=%s\n", $state);
|
||||||
|
foreach (pictureMigrationColumns() as [$table, $field]) {
|
||||||
|
$capacity = pictureColumnCapacity($db, $table, $field);
|
||||||
|
$statusKey = $table === 'general' && $field === 'picture'
|
||||||
|
? 'picture_capacity'
|
||||||
|
: "{$table}_{$field}_capacity";
|
||||||
|
printf("%s=%s\n", $statusKey, $capacity ?? 'unknown');
|
||||||
|
}
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
$field = "l{$level}imgsvr";
|
||||||
|
$column = migrationColumnInfo($db, 'emperior', $field);
|
||||||
|
printf(
|
||||||
|
"emperior_%s=%s\n",
|
||||||
|
$field,
|
||||||
|
$column === null ? 'missing' : strtolower((string)$column['Type']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$unresolved = unresolvedEmperiorImgsvrCount($db);
|
||||||
|
printf("unresolved_emperior_imgsvr=%s\n", $unresolved ?? 'unknown');
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePictureMigrationBackup(mixed $backup): string
|
||||||
|
{
|
||||||
|
if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) {
|
||||||
|
fwrite(STDERR, "--backup must name a pre-existing, non-empty absolute SQL backup made immediately before migration.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
return $backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquirePictureMigrationLock(\MeekroDB $db, string $server): bool
|
||||||
|
{
|
||||||
|
return (int)$db->queryFirstField(
|
||||||
|
'SELECT GET_LOCK(%s, 0)',
|
||||||
|
"sammo-picture-migration-$server",
|
||||||
|
) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePictureMigrationLock(\MeekroDB $db, string $server): void
|
||||||
|
{
|
||||||
|
$db->queryFirstField(
|
||||||
|
'SELECT RELEASE_LOCK(%s)',
|
||||||
|
"sammo-picture-migration-$server",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = DB::db();
|
||||||
|
if (isset($options['status'])) {
|
||||||
|
exit(printPictureMigrationStatus($db, $server) === 'unsupported' ? 2 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = pictureMigrationState($db);
|
||||||
|
if ($state === 'unsupported') {
|
||||||
|
fwrite(STDERR, "One or more picture columns have an unsupported schema; inspect --status first.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recoverableBefore = $state === 'ready' ? recoverableEmperiorImgsvrCount($db) : 0;
|
||||||
|
if ($state === 'ready' && $recoverableBefore === 0) {
|
||||||
|
fwrite(STDOUT, "Picture schema for $server is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n");
|
||||||
|
printPictureMigrationStatus($db, $server);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePictureMigrationBackup($options['backup'] ?? null);
|
||||||
|
$serverClosed = isset($options['server-closed']);
|
||||||
|
if ($serverClosed) {
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
"WARNING: --server-closed skips the GAME lock. Continue only if web and daemon traffic for $server is already stopped.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!acquirePictureMigrationLock($db, $server)) {
|
||||||
|
fwrite(STDERR, "Another picture migration is already running for $server.\n");
|
||||||
|
exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backfilled = 0;
|
||||||
|
$acquiredGameLock = false;
|
||||||
|
if (!$serverClosed && !\sammo\tryLock()) {
|
||||||
|
releasePictureMigrationLock($db, $server);
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
"Unable to acquire the GAME lock. If the server is intentionally closed and all web/daemon traffic is stopped, rerun with --server-closed.\n",
|
||||||
|
);
|
||||||
|
exit(3);
|
||||||
|
}
|
||||||
|
if (!$serverClosed) {
|
||||||
|
$acquiredGameLock = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pictureColumnCapacity($db, 'general', 'picture') === 40) {
|
||||||
|
$db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
$emperiorClauses = [];
|
||||||
|
foreach (emperiorPictureLevels() as $level) {
|
||||||
|
$pictureField = "l{$level}pic";
|
||||||
|
$imgsvrField = "l{$level}imgsvr";
|
||||||
|
if (pictureColumnCapacity($db, 'emperior', $pictureField) === 32) {
|
||||||
|
$emperiorClauses[] = "MODIFY `$pictureField` VARCHAR(64) NULL DEFAULT ''";
|
||||||
|
}
|
||||||
|
if (migrationColumnInfo($db, 'emperior', $imgsvrField) === null) {
|
||||||
|
$emperiorClauses[] = "ADD COLUMN `$imgsvrField` INT(1) NULL DEFAULT NULL AFTER `$pictureField`";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($emperiorClauses !== []) {
|
||||||
|
$db->query('ALTER TABLE emperior ' . implode(', ', $emperiorClauses));
|
||||||
|
}
|
||||||
|
|
||||||
|
$backfilled = backfillEmperiorImgsvr($db);
|
||||||
|
} finally {
|
||||||
|
if ($acquiredGameLock) {
|
||||||
|
\sammo\unlock();
|
||||||
|
}
|
||||||
|
releasePictureMigrationLock($db, $server);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printPictureMigrationStatus($db, $server) !== 'ready') {
|
||||||
|
fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n");
|
||||||
|
exit(4);
|
||||||
|
}
|
||||||
|
printf("Picture-column migration for %s completed; backfilled_imgsvr=%d.\n", $server, $backfilled);
|
||||||
@@ -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);
|
||||||
@@ -6,20 +6,28 @@ phases AS (
|
|||||||
g.winner_nation,
|
g.winner_nation,
|
||||||
e.l12name,
|
e.l12name,
|
||||||
e.l12pic,
|
e.l12pic,
|
||||||
|
e.l12imgsvr,
|
||||||
e.l11name,
|
e.l11name,
|
||||||
e.l11pic,
|
e.l11pic,
|
||||||
|
e.l11imgsvr,
|
||||||
e.l10name,
|
e.l10name,
|
||||||
e.l10pic,
|
e.l10pic,
|
||||||
|
e.l10imgsvr,
|
||||||
e.l9name,
|
e.l9name,
|
||||||
e.l9pic,
|
e.l9pic,
|
||||||
|
e.l9imgsvr,
|
||||||
e.l8name,
|
e.l8name,
|
||||||
e.l8pic,
|
e.l8pic,
|
||||||
|
e.l8imgsvr,
|
||||||
e.l7name,
|
e.l7name,
|
||||||
e.l7pic,
|
e.l7pic,
|
||||||
|
e.l7imgsvr,
|
||||||
e.l6name,
|
e.l6name,
|
||||||
e.l6pic,
|
e.l6pic,
|
||||||
|
e.l6imgsvr,
|
||||||
e.l5name,
|
e.l5name,
|
||||||
e.l5pic
|
e.l5pic,
|
||||||
|
e.l5imgsvr
|
||||||
FROM emperior e
|
FROM emperior e
|
||||||
LEFT JOIN ng_games g ON g.server_id = e.server_id
|
LEFT JOIN ng_games g ON g.server_id = e.server_id
|
||||||
WHERE e.no BETWEEN 1 AND 99
|
WHERE e.no BETWEEN 1 AND 99
|
||||||
@@ -63,21 +71,21 @@ selection_reasons AS (
|
|||||||
WHERE hall_rank <= 10
|
WHERE hall_rank <= 10
|
||||||
),
|
),
|
||||||
chief_slots AS (
|
chief_slots AS (
|
||||||
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture FROM phases
|
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture, l12imgsvr AS imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic, l11imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic, l10imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic, l9imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic, l8imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic, l7imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic, l6imgsvr FROM phases
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases
|
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic, l5imgsvr FROM phases
|
||||||
),
|
),
|
||||||
chief_reasons AS (
|
chief_reasons AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -94,6 +102,10 @@ chief_reasons AS (
|
|||||||
'?=',
|
'?=',
|
||||||
1
|
1
|
||||||
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
|
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
|
||||||
|
AND (
|
||||||
|
c.imgsvr IS NULL
|
||||||
|
OR CAST(COALESCE(JSON_VALUE(og.data, '$.imgsvr'), -1) AS SIGNED) = c.imgsvr
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
|
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
|
||||||
OR (
|
OR (
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace sammo;
|
||||||
|
|
||||||
|
final class ImageSyncClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{body:string,headers:list<string>,requestId:string}
|
||||||
|
*/
|
||||||
|
public static function buildRequest(
|
||||||
|
string $client,
|
||||||
|
string $secret,
|
||||||
|
?string $commit = null,
|
||||||
|
?int $timestampMs = null,
|
||||||
|
?string $requestId = null
|
||||||
|
): array {
|
||||||
|
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid image sync client');
|
||||||
|
}
|
||||||
|
if (strlen($secret) < 32) {
|
||||||
|
throw new \InvalidArgumentException('Image sync secret must be at least 32 characters');
|
||||||
|
}
|
||||||
|
if ($commit !== null && !preg_match('/^[0-9a-f]{40,64}$/i', $commit)) {
|
||||||
|
throw new \InvalidArgumentException('Image commit must be a full Git SHA');
|
||||||
|
}
|
||||||
|
$body = Json::encode($commit === null ? (object)[] : ['commit' => $commit]);
|
||||||
|
$timestamp = (string)($timestampMs ?? (int)floor(microtime(true) * 1000));
|
||||||
|
$requestId ??= bin2hex(random_bytes(16));
|
||||||
|
$signature = hash_hmac('sha256', "{$timestamp}.{$requestId}.{$body}", $secret);
|
||||||
|
return [
|
||||||
|
'body' => $body,
|
||||||
|
'requestId' => $requestId,
|
||||||
|
'headers' => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
"X-Image-Client: {$client}",
|
||||||
|
"X-Image-Timestamp: {$timestamp}",
|
||||||
|
"X-Image-Request-Id: {$requestId}",
|
||||||
|
"X-Image-Signature: {$signature}",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
public static function sync(string $url, string $client, string $secret, ?string $commit = null): array
|
||||||
|
{
|
||||||
|
$scheme = parse_url($url, PHP_URL_SCHEME);
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
|
||||||
|
throw new \InvalidArgumentException('Image sync URL must use HTTPS except for loopback tests');
|
||||||
|
}
|
||||||
|
$request = self::buildRequest($client, $secret, $commit);
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
throw new \RuntimeException('Unable to initialize image sync request');
|
||||||
|
}
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => $request['headers'],
|
||||||
|
CURLOPT_POSTFIELDS => $request['body'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
if ($response === false) {
|
||||||
|
throw new \RuntimeException("Image sync request failed: {$error}");
|
||||||
|
}
|
||||||
|
$decoded = Json::decode($response);
|
||||||
|
if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) {
|
||||||
|
throw new \RuntimeException("Image sync rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error'));
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,14 +146,64 @@ final class GameClockBoundaryTest extends TestCase
|
|||||||
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue'));
|
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void
|
public function testGatewayReadsClockOnlyAfterClosedReservationResponse(): void
|
||||||
|
{
|
||||||
|
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
|
||||||
|
self::assertIsString($source);
|
||||||
|
|
||||||
|
$closedBranch = strpos($source, "if(file_exists(__DIR__.'/.htaccess'))");
|
||||||
|
$closedBranchEnd = strpos($source, '//TODO: 천통시에도 예약 오픈 알림이 필요..?');
|
||||||
|
$clockDetection = strpos($source, 'GameClock::isInitialized($gameStor)');
|
||||||
|
$clockRead = strpos($source, 'GameClock::fromStorage($gameStor)');
|
||||||
|
|
||||||
|
self::assertNotFalse($closedBranch);
|
||||||
|
self::assertNotFalse($closedBranchEnd);
|
||||||
|
self::assertNotFalse($clockDetection);
|
||||||
|
self::assertNotFalse($clockRead);
|
||||||
|
self::assertGreaterThan($closedBranch, $closedBranchEnd);
|
||||||
|
self::assertGreaterThan($closedBranchEnd, $clockDetection);
|
||||||
|
self::assertGreaterThan($closedBranchEnd, $clockRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGatewayPreservesWallClockProfilesAndFormatsLogicalOpenTime(): void
|
||||||
{
|
{
|
||||||
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
|
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
|
||||||
self::assertIsString($source);
|
self::assertIsString($source);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'$usesLogicalClock = GameClock::isInitialized($gameStor);',
|
||||||
|
$source,
|
||||||
|
);
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
|
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
|
||||||
$source,
|
$source,
|
||||||
);
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'$admin[\'isOpen\'] = new \\DateTimeImmutable((string)$admin[\'opentime\']) <= GameClock::readWallTime();',
|
||||||
|
$source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNationGeneralListProjectsStoredTimesAtApiBoundary(): void
|
||||||
|
{
|
||||||
|
$source = file_get_contents(__DIR__ . '/../hwe/sammo/API/Nation/GeneralList.php');
|
||||||
|
self::assertIsString($source);
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;',
|
||||||
|
$source,
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'$env[\'turntime\'] = $formatStoredTime($env[\'turntime\']);',
|
||||||
|
$source,
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID][\'turntime\']);',
|
||||||
|
$source,
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"? (string)\$value",
|
||||||
|
$source,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ final class GameClockTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
self::assertFalse(GameClock::isInitialized($legacyStorage));
|
self::assertFalse(GameClock::isInitialized($legacyStorage));
|
||||||
|
|
||||||
|
$reservedResetStorage = $this->createMock(KVStorage::class);
|
||||||
|
$reservedResetStorage->method('getValues')->willReturn([]);
|
||||||
|
self::assertFalse(GameClock::isInitialized($reservedResetStorage));
|
||||||
|
|
||||||
$partialStorage = $this->createMock(KVStorage::class);
|
$partialStorage = $this->createMock(KVStorage::class);
|
||||||
$partialStorage->method('getValues')->willReturn([
|
$partialStorage->method('getValues')->willReturn([
|
||||||
'clock_tick' => 0,
|
'clock_tick' => 0,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace sammo;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GeneralPictureSchemaTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $arguments
|
||||||
|
* @return array{int, string, string}
|
||||||
|
*/
|
||||||
|
private function runMigrationCommand(array $arguments): array
|
||||||
|
{
|
||||||
|
$command = array_merge(
|
||||||
|
[PHP_BINARY, __DIR__ . '/../scripts/migrate-general-picture.php'],
|
||||||
|
$arguments,
|
||||||
|
);
|
||||||
|
$pipes = [];
|
||||||
|
$process = proc_open(
|
||||||
|
$command,
|
||||||
|
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||||
|
$pipes,
|
||||||
|
);
|
||||||
|
self::assertIsResource($process);
|
||||||
|
$stdout = stream_get_contents($pipes[1]);
|
||||||
|
$stderr = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$exitCode = proc_close($process);
|
||||||
|
self::assertIsString($stdout);
|
||||||
|
self::assertIsString($stderr);
|
||||||
|
return [$exitCode, $stdout, $stderr];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGameAndAccountSchemasAcceptRemoteUserIconPaths(): void
|
||||||
|
{
|
||||||
|
$gameSchema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql');
|
||||||
|
$accountSchema = file_get_contents(__DIR__ . '/../f_install/sql/common_schema.sql');
|
||||||
|
self::assertIsString($gameSchema);
|
||||||
|
self::assertIsString($accountSchema);
|
||||||
|
self::assertMatchesRegularExpression('/`picture`\s+VARCHAR\(64\)\s+NOT NULL/i', $gameSchema);
|
||||||
|
self::assertMatchesRegularExpression('/`PICTURE`\s+VARCHAR\(64\)/i', $accountSchema);
|
||||||
|
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
sprintf('/`l%dpic`\s+VARCHAR\(64\)/i', $level),
|
||||||
|
$gameSchema,
|
||||||
|
);
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
sprintf('/`l%dimgsvr`\s+INT\(1\)\s+NULL\s+DEFAULT\s+NULL/i', $level),
|
||||||
|
$gameSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$longestRemotePath = 'users/core/' . str_repeat('a', 32) . '.jpeg?=20260807';
|
||||||
|
self::assertGreaterThan(40, strlen($longestRemotePath));
|
||||||
|
self::assertLessThanOrEqual(64, strlen($longestRemotePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExistingGameMigrationWidensAllConstrainedPictureColumns(): void
|
||||||
|
{
|
||||||
|
$migration = file_get_contents(__DIR__ . '/../scripts/migrate-general-picture.php');
|
||||||
|
self::assertIsString($migration);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL',
|
||||||
|
$migration,
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('"l{$level}pic"', $migration);
|
||||||
|
self::assertStringContainsString('"l{$level}imgsvr"', $migration);
|
||||||
|
self::assertStringContainsString("ALTER TABLE emperior", $migration);
|
||||||
|
self::assertStringContainsString("ADD COLUMN `\$imgsvrField` INT(1) NULL DEFAULT NULL", $migration);
|
||||||
|
self::assertStringContainsString('HAVING COUNT(*) = 1', $migration);
|
||||||
|
self::assertStringContainsString('ambiguous historical values remain NULL', $migration);
|
||||||
|
self::assertStringContainsString("? 'picture_capacity'", $migration);
|
||||||
|
self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']", $migration);
|
||||||
|
self::assertStringContainsString("require \$serverDirectory . '/lib.php'", $migration);
|
||||||
|
self::assertStringContainsString("require \$serverDirectory . '/func.php'", $migration);
|
||||||
|
self::assertStringNotContainsString("'/hwe/lib.php'", $migration);
|
||||||
|
self::assertStringNotContainsString("'/hwe/func.php'", $migration);
|
||||||
|
self::assertStringContainsString('SELECT GET_LOCK(%s, 0)', $migration);
|
||||||
|
self::assertStringContainsString('SELECT RELEASE_LOCK(%s)', $migration);
|
||||||
|
self::assertStringContainsString('if (!$serverClosed && !\\sammo\\tryLock())', $migration);
|
||||||
|
self::assertStringContainsString('if ($acquiredGameLock)', $migration);
|
||||||
|
self::assertStringContainsString('--server-closed skips the GAME lock', $migration);
|
||||||
|
self::assertStringNotContainsString('UPDATE general', $migration);
|
||||||
|
self::assertStringContainsString("\$state === 'ready'", $migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnificationPreservesChiefImageServerAndCentennialMatchingUsesIt(): void
|
||||||
|
{
|
||||||
|
$gameRule = file_get_contents(__DIR__ . '/../hwe/func_gamerule.php');
|
||||||
|
$candidateSql = file_get_contents(__DIR__ . '/../src/centennial_allstar_candidates.sql');
|
||||||
|
self::assertIsString($gameRule);
|
||||||
|
self::assertIsString($candidateSql);
|
||||||
|
self::assertStringContainsString('name,picture,imgsvr,belong,officer_level', $gameRule);
|
||||||
|
foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) {
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"'l{$level}imgsvr' => \$chiefs[{$level}]['imgsvr']",
|
||||||
|
$gameRule,
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("e.l{$level}imgsvr", $candidateSql);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('c.imgsvr IS NULL', $candidateSql);
|
||||||
|
self::assertStringContainsString("JSON_VALUE(og.data, '$.imgsvr')", $candidateSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMigrationRequiresAnExplicitSafeServerPrefixBeforeLoadingConfiguration(): void
|
||||||
|
{
|
||||||
|
[$helpExit, $helpOutput, $helpError] = $this->runMigrationCommand(['--help']);
|
||||||
|
self::assertSame(0, $helpExit);
|
||||||
|
self::assertStringContainsString('--server=PREFIX', $helpOutput);
|
||||||
|
self::assertStringContainsString('--server-closed', $helpOutput);
|
||||||
|
self::assertSame('', $helpError);
|
||||||
|
|
||||||
|
[$missingExit, $missingOutput, $missingError] = $this->runMigrationCommand(['--status']);
|
||||||
|
self::assertSame(2, $missingExit);
|
||||||
|
self::assertSame('', $missingOutput);
|
||||||
|
self::assertStringContainsString('--server must name one game-server directory', $missingError);
|
||||||
|
|
||||||
|
[$traversalExit, $traversalOutput, $traversalError] = $this->runMigrationCommand([
|
||||||
|
'--server=../hwe',
|
||||||
|
'--status',
|
||||||
|
]);
|
||||||
|
self::assertSame(2, $traversalExit);
|
||||||
|
self::assertSame('', $traversalOutput);
|
||||||
|
self::assertStringContainsString('--server must name one game-server directory', $traversalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testScriptsDirectoryIsDeniedOverApache(): void
|
||||||
|
{
|
||||||
|
$accessRules = file_get_contents(__DIR__ . '/../scripts/.htaccess');
|
||||||
|
self::assertIsString($accessRules);
|
||||||
|
self::assertStringContainsString('Require all denied', $accessRules);
|
||||||
|
self::assertStringContainsString('Deny from all', $accessRules);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use sammo\ImageSyncClient;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/src/sammo/ImageSyncClient.php';
|
||||||
|
|
||||||
|
final class ImageSyncClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBuildRequestSignsTheExactBody(): void
|
||||||
|
{
|
||||||
|
$secret = str_repeat('c', 32);
|
||||||
|
$request = ImageSyncClient::buildRequest(
|
||||||
|
'core',
|
||||||
|
$secret,
|
||||||
|
str_repeat('a', 40),
|
||||||
|
1786013000000,
|
||||||
|
'core-request-1234'
|
||||||
|
);
|
||||||
|
$headers = implode("\n", $request['headers']);
|
||||||
|
$expected = hash_hmac(
|
||||||
|
'sha256',
|
||||||
|
"1786013000000.core-request-1234.{$request['body']}",
|
||||||
|
$secret
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("X-Image-Signature: {$expected}", $headers);
|
||||||
|
self::assertSame('{"commit":"' . str_repeat('a', 40) . '"}', $request['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildRequestRejectsAbbreviatedCommit(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user