merge: validate invader resume watchdog fix

This commit is contained in:
2026-08-12 15:35:57 +00:00
37 changed files with 1778 additions and 131 deletions
+40 -5
View File
@@ -19,6 +19,30 @@ class LoginByToken extends LoginByID
{
static array $sensitiveArgs = ['hashedToken'];
public static function kakaoFailureResponse(bool $reqOTP, string $reason): string | array
{
if (!$reqOTP) {
return $reason;
}
return [
'result' => 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);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace sammo;
/**
* Wall-clock inactivity anchor used only by the reserved reset watchdog.
*
* GameClock ticks remain authoritative for gameplay. This timestamp records
* when a united or invader game last changed phase or actually advanced, so
* j_autoreset can distinguish a healthy running event from a stalled server.
*/
final class AutoResetProgress
{
public const STORAGE_KEY = 'autoreset_united_wall_anchor';
private function __construct()
{
}
public static function record(
KVStorage $gameStorage,
?\DateTimeInterface $wallTime = null,
): void {
$wallTime ??= GameClock::readWallTime();
$gameStorage->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;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace sammo;
final class ImageSyncClient
{
/**
* @return array{body:string,headers:list<string>,requestId:string}
*/
public static function buildRequest(
string $client,
string $secret,
?string $commit = null,
?int $timestampMs = null,
?string $requestId = null
): array {
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) {
throw new \InvalidArgumentException('Invalid image sync client');
}
if (strlen($secret) < 32) {
throw new \InvalidArgumentException('Image sync secret must be at least 32 characters');
}
if ($commit !== null && !preg_match('/^[0-9a-f]{40,64}$/i', $commit)) {
throw new \InvalidArgumentException('Image commit must be a full Git SHA');
}
$body = Json::encode($commit === null ? (object)[] : ['commit' => $commit]);
$timestamp = (string)($timestampMs ?? (int)floor(microtime(true) * 1000));
$requestId ??= bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', "{$timestamp}.{$requestId}.{$body}", $secret);
return [
'body' => $body,
'requestId' => $requestId,
'headers' => [
'Content-Type: application/json',
"X-Image-Client: {$client}",
"X-Image-Timestamp: {$timestamp}",
"X-Image-Request-Id: {$requestId}",
"X-Image-Signature: {$signature}",
],
];
}
/** @return array<string,mixed> */
public static function sync(string $url, string $client, string $secret, ?string $commit = null): array
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
throw new \InvalidArgumentException('Image sync URL must use HTTPS except for loopback tests');
}
$request = self::buildRequest($client, $secret, $commit);
$curl = curl_init($url);
if ($curl === false) {
throw new \RuntimeException('Unable to initialize image sync request');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $request['headers'],
CURLOPT_POSTFIELDS => $request['body'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($response === false) {
throw new \RuntimeException("Image sync request failed: {$error}");
}
$decoded = Json::decode($response);
if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) {
throw new \RuntimeException("Image sync rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error'));
}
return $decoded;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace sammo;
final class RemoteImageUploadException extends \RuntimeException
{
}
final class RemoteUserIconUploadClient
{
private const SAFE_EXACT_ERRORS = [
'Invalid image upload client',
'Image upload secret must be at least 32 characters',
'Invalid image upload URL',
'Image upload URL must use HTTPS except for loopback tests',
'Remote user icon upload is not configured',
'Remote user icon upload secret file is not configured',
'Remote user icon upload secret file cannot be read',
'Remote user icon upload secret is too short',
'Unable to initialize image upload request',
'Image upload returned invalid JSON',
'Image upload returned an unsuccessful response',
'Image upload returned an unexpected path',
];
public static function isConfiguredEnabled(): bool
{
return property_exists(ServConfig::class, 'remoteUserIconUploadEnabled')
&& ServConfig::$remoteUserIconUploadEnabled === true;
}
/** @return array<string,mixed> */
public static function uploadConfigured(string $filename, string $contentType, string $body): array
{
[$baseUrl, $secret] = self::configuredBaseUrlAndSecret();
return self::upload(
"{$baseUrl}/v1/uploads/user-icons/core/{$filename}",
'core',
$secret,
$contentType,
$body
);
}
/** @return array<string,mixed> */
public static function uploadContentConfigured(string $filename, string $contentType, string $body): array
{
[$baseUrl, $secret] = self::configuredBaseUrlAndSecret();
return self::upload(
"{$baseUrl}/v1/uploads/content/core/{$filename}",
'core',
$secret,
$contentType,
$body
);
}
public static function getConfiguredContentPublicUrl(string $filename): string
{
[$baseUrl] = self::configuredBaseUrlAndSecret();
return "{$baseUrl}/uploads/core/{$filename}";
}
/**
* Record a caught upload failure in both the PHP service log and the
* operator-facing SQLite log without persisting request arguments, response
* bodies, headers, or secret values.
*/
public static function logFailure(
string $operation,
\Throwable $error,
?callable $systemLogger = null,
?callable $structuredLogger = null
): void {
$label = match ($operation) {
'user-icon' => 'Remote user icon upload',
'content-image' => 'Remote content image upload',
default => 'Remote image upload',
};
$reason = self::safeFailureReason($error);
$message = "{$label} failed: {$reason}";
if ($systemLogger === null) {
error_log($message);
} else {
$systemLogger($message);
}
try {
$arguments = [
'RemoteImageUploadFailure',
$message,
$error->getFile() . ':' . $error->getLine(),
[],
];
if ($structuredLogger === null) {
logError(...$arguments);
} else {
$structuredLogger(...$arguments);
}
} catch (\Throwable $loggingError) {
error_log('Remote image upload structured logging failed: ' . get_debug_type($loggingError));
}
}
private static function safeFailureReason(\Throwable $error): string
{
if ($error instanceof RemoteImageUploadException || $error instanceof \InvalidArgumentException) {
$message = $error->getMessage();
if (in_array($message, self::SAFE_EXACT_ERRORS, true)
|| ($error instanceof RemoteImageUploadException
&& preg_match('/^(?:Image upload rejected \([1-5][0-9]{2}\)|Image upload request failed \(cURL [0-9]+\))$/D', $message))) {
return $message;
}
}
return 'Unexpected ' . get_debug_type($error);
}
/** @return array{string,string} */
private static function configuredBaseUrlAndSecret(): array
{
if (!self::isConfiguredEnabled()
|| !property_exists(ServConfig::class, 'remoteUserIconUploadPath')
|| !property_exists(ServConfig::class, 'remoteUserIconUploadSecretFile')) {
throw new RemoteImageUploadException('Remote user icon upload is not configured');
}
$secretPath = ServConfig::$remoteUserIconUploadSecretFile;
if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) {
throw new RemoteImageUploadException('Remote user icon upload secret file is not configured');
}
if ($secretPath[0] !== '/') {
$secretPath = ROOT . '/' . $secretPath;
}
$secretContents = @file_get_contents($secretPath);
if ($secretContents === false) {
throw new RemoteImageUploadException('Remote user icon upload secret file cannot be read');
}
$secret = trim($secretContents);
if (strlen($secret) < 32) {
throw new RemoteImageUploadException('Remote user icon upload secret is too short');
}
return [rtrim((string)ServConfig::$remoteUserIconUploadPath, '/'), $secret];
}
/** @return array{headers:list<string>,requestId:string,expires:string} */
public static function buildRequest(
string $url,
string $client,
string $secret,
string $contentType,
string $body,
?int $expires = null,
?string $requestId = null
): array {
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) {
throw new \InvalidArgumentException('Invalid image upload client');
}
if (strlen($secret) < 32) {
throw new \InvalidArgumentException('Image upload secret must be at least 32 characters');
}
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path) || !preg_match('#^/v1/uploads/(?:user-icons|content)/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$#', $path)) {
throw new \InvalidArgumentException('Invalid image upload URL');
}
$expiresText = (string)($expires ?? time() + 60);
$requestId ??= bin2hex(random_bytes(16));
$digest = hash('sha256', $body);
$signature = hash_hmac(
'sha256',
"{$expiresText}.{$requestId}.{$path}.{$contentType}.{$digest}",
$secret
);
return [
'requestId' => $requestId,
'expires' => $expiresText,
'headers' => [
"Content-Type: {$contentType}",
"X-Image-Client: {$client}",
"X-Image-Expires: {$expiresText}",
"X-Image-Request-Id: {$requestId}",
"X-Image-Signature: {$signature}",
],
];
}
/** @return array<string,mixed> */
public static function upload(string $url, string $client, string $secret, string $contentType, string $body): array
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
throw new \InvalidArgumentException('Image upload URL must use HTTPS except for loopback tests');
}
$request = self::buildRequest($url, $client, $secret, $contentType, $body);
$curl = curl_init($url);
if ($curl === false) {
throw new RemoteImageUploadException('Unable to initialize image upload request');
}
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_HTTPHEADER => $request['headers'],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$curlErrorNumber = curl_errno($curl);
curl_close($curl);
if ($response === false) {
throw new RemoteImageUploadException("Image upload request failed (cURL {$curlErrorNumber})");
}
if ($status < 200 || $status >= 300) {
throw new RemoteImageUploadException("Image upload rejected ({$status})");
}
try {
$decoded = Json::decode($response);
} catch (\Throwable $error) {
throw new RemoteImageUploadException('Image upload returned invalid JSON', previous: $error);
}
if (!($decoded['ok'] ?? false)) {
throw new RemoteImageUploadException('Image upload returned an unsuccessful response');
}
$filename = basename((string)parse_url($url, PHP_URL_PATH));
$category = str_contains((string)parse_url($url, PHP_URL_PATH), '/content/') ? 'content' : 'user-icons';
$expectedPath = $category === 'content'
? "uploads/{$client}/{$filename}"
: "icons/users/{$client}/{$filename}";
if (($decoded['path'] ?? null) !== $expectedPath) {
throw new RemoteImageUploadException('Image upload returned an unexpected path');
}
return $decoded;
}
}