From f3bf490df3470c83529cdd782ce320ada700ff09 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 14:59:44 +0000 Subject: [PATCH] fix: expose safe remote upload errors --- hwe/sammo/API/Misc/UploadImage.php | 2 +- i_entrance/j_icon_change.php | 2 +- src/sammo/RemoteUserIconUploadClient.php | 108 ++++++++++++++++++++--- tests/RemoteUserIconUploadClientTest.php | 65 ++++++++++++++ 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/hwe/sammo/API/Misc/UploadImage.php b/hwe/sammo/API/Misc/UploadImage.php index c58f419a..519a97d6 100644 --- a/hwe/sammo/API/Misc/UploadImage.php +++ b/hwe/sammo/API/Misc/UploadImage.php @@ -67,7 +67,7 @@ class UploadImage extends \sammo\BaseAPI RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData); $remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName); } catch (\Throwable $error) { - error_log('Remote content image upload failed: ' . $error->getMessage()); + RemoteUserIconUploadClient::logFailure('content-image', $error); return '원격 이미지 저장소 업로드에 실패했습니다!'; } } else { diff --git a/i_entrance/j_icon_change.php b/i_entrance/j_icon_change.php index 3b77822d..3e7bb3a8 100644 --- a/i_entrance/j_icon_change.php +++ b/i_entrance/j_icon_change.php @@ -90,7 +90,7 @@ if(!is_uploaded_file($image['tmp_name'])) { $newPicName = "users/core/{$remoteName}"; $storedRemotely = true; } catch (\Throwable $error) { - error_log('Remote user icon upload failed: ' . $error->getMessage()); + RemoteUserIconUploadClient::logFailure('user-icon', $error); $storedRemotely = false; } } else { diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php index 1ae7aa28..047ca72d 100644 --- a/src/sammo/RemoteUserIconUploadClient.php +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -2,8 +2,27 @@ 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') @@ -42,22 +61,84 @@ final class RemoteUserIconUploadClient 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 \RuntimeException('Remote user icon upload is not configured'); + throw new RemoteImageUploadException('Remote user icon upload is not configured'); } $secretPath = ServConfig::$remoteUserIconUploadSecretFile; if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) { - throw new \RuntimeException('Remote user icon upload secret file is not configured'); + throw new RemoteImageUploadException('Remote user icon upload secret file is not configured'); } if ($secretPath[0] !== '/') { $secretPath = ROOT . '/' . $secretPath; } - $secret = trim((string)file_get_contents($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]; } @@ -113,7 +194,7 @@ final class RemoteUserIconUploadClient $request = self::buildRequest($url, $client, $secret, $contentType, $body); $curl = curl_init($url); if ($curl === false) { - throw new \RuntimeException('Unable to initialize image upload request'); + throw new RemoteImageUploadException('Unable to initialize image upload request'); } curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => 'PUT', @@ -125,14 +206,21 @@ final class RemoteUserIconUploadClient ]); $response = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - $error = curl_error($curl); + $curlErrorNumber = curl_errno($curl); curl_close($curl); if ($response === false) { - throw new \RuntimeException("Image upload request failed: {$error}"); + throw new RemoteImageUploadException("Image upload request failed (cURL {$curlErrorNumber})"); } - $decoded = Json::decode($response); - if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { - throw new \RuntimeException("Image upload rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); + 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'; @@ -140,7 +228,7 @@ final class RemoteUserIconUploadClient ? "uploads/{$client}/{$filename}" : "icons/users/{$client}/{$filename}"; if (($decoded['path'] ?? null) !== $expectedPath) { - throw new \RuntimeException('Image upload returned an unexpected path'); + throw new RemoteImageUploadException('Image upload returned an unexpected path'); } return $decoded; } diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php index a62bfe60..6703f9bd 100644 --- a/tests/RemoteUserIconUploadClientTest.php +++ b/tests/RemoteUserIconUploadClientTest.php @@ -1,6 +1,7 @@