fix: expose safe remote upload errors
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user