From 037f888fcca45041fef039227281643640028b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Felix=20=C5=A0ulc?= Date: Fri, 25 Nov 2022 19:43:15 +0100 Subject: [PATCH] Uploader: initial support of storing errors in S3-like storage --- .docs/README.md | 23 ++++ README.md | 1 - composer.json | 3 + src/Exception/Runtime/UploadException.php | 10 ++ src/Integration/S3UploadIntegration.php | 63 ++++++++++ src/Upload/S3Signer.php | 134 ++++++++++++++++++++++ src/Upload/S3Uploader.php | 65 +++++++++++ 7 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/Exception/Runtime/UploadException.php create mode 100644 src/Integration/S3UploadIntegration.php create mode 100644 src/Upload/S3Signer.php create mode 100644 src/Upload/S3Uploader.php diff --git a/.docs/README.md b/.docs/README.md index c3eddc8..045871b 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -200,6 +200,29 @@ sentry: Be careful with `"` and `'`. It does matter. +**S3UploadIntegration** + +Upload **ladenka** (error file) to S3. URL is stored as tag in Sentry. + +```neon +sentry: + client: + integrations: + - Contributte\Sentry\Integration\S3UploadIntegration() + +services: + - Contributte\Sentry\Upload\S3Uploader( + Contributte\Sentry\Upload\S3Signer( + accessKeyId: secret + secretKey: secret + url: myorg.r2.cloudflarestorage.com / s3.eu-central-1.amazonaws.com + bucket: mybucket + region: auto + prefix: null + ) + ) +``` + ## Usage Sentry is successfully integrated to your [Nette](https://nette.org) application and listen for any errors using [Tracy](https://tracy.nette.org). diff --git a/README.md b/README.md index 09cff4d..d1fc049 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ For details on how to use this package, check out our [documentation](.docs). | dev | `^0.2` | `master` | 2.4+ | `>=7.2` | | stable | `^0.1` | `master` | 2.4+ | `>=7.2` | - ## Development See [how to contribute](https://contributte.org) to this package. This package is currently maintained by these authors. diff --git a/composer.json b/composer.json index 63125f1..3926868 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,9 @@ "phpstan/phpstan-strict-rules": "^1.1.0", "tracy/tracy": "^2.6.0" }, + "suggest": { + "ext-curl": "S3 upload" + }, "conflict": { "nette/utils": "<2.5.7", "php-http/discovery": "<1.14.0" diff --git a/src/Exception/Runtime/UploadException.php b/src/Exception/Runtime/UploadException.php new file mode 100644 index 0000000..518f1e3 --- /dev/null +++ b/src/Exception/Runtime/UploadException.php @@ -0,0 +1,10 @@ +context = $context; + } + + public function setup(HubInterface $hub, Event $event, EventHint $hint): ?Event + { + /** @var S3Uploader|null $uploader */ + $uploader = $this->context->getByType(S3Uploader::class, false); + + // Required services are missing + if ($uploader === null) { + return $event; + } + + $exception = $hint->exception; + + // No exception + if ($exception === null) { + return $event; + } + + // Use logger from Tracy to calculate filename + $logger = new Logger(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen()); + $file = $logger->getExceptionFile($exception); + + // Render bluescreen to file + $bs = Debugger::getBlueScreen(); + $bs->renderToFile($exception, $file); + + // Upload file + try { + $uploaded = $uploader->upload($file); + $event->setTags([ + 'tracy_file' => $uploaded['url'], + ]); + } catch (UploadException $e) { + // Do nothing + } + + return $event; + } + +} diff --git a/src/Upload/S3Signer.php b/src/Upload/S3Signer.php new file mode 100644 index 0000000..8172e60 --- /dev/null +++ b/src/Upload/S3Signer.php @@ -0,0 +1,134 @@ +accessKey = $accessKeyId; + $this->secretKey = $secretKey; + $this->url = $url; + $this->bucket = $bucket; + $this->region = $region; + $this->prefix = $prefix; + + $this->date = new DateTime('UTC'); + } + + /** + * @return array{url: string, headers: array} + */ + public function sign(string $path): array + { + $fullpath = sprintf('/%s/%s', $this->bucket, ($this->prefix !== null ? trim($this->prefix, '/') . '/' : '') . $path); + $url = sprintf('https://%s%s', $this->url, $fullpath); + + $headers = [ + 'Host' => $this->url, + 'X-Amz-Date' => $this->date->format('Ymd\THis\Z'), + 'X-Amz-Content-Sha256' => 'UNSIGNED-PAYLOAD', + ]; + + $headers['Authorization'] = $this->doAuthorization($fullpath, $headers); + $headers['Content-Type'] = 'text/html; charset=utf-8'; + + return ['url' => $url, 'headers' => $headers]; + } + + + /** + * @param array $headers + */ + protected function doAuthorization(string $path, array $headers): string + { + $method = 'PUT'; + $query = ''; + $payloadHash = 'UNSIGNED-PAYLOAD'; + $service = 's3'; + + $longDate = $this->date->format('Ymd\THis\Z'); + + // Sort headers by key + $sortedHeaders = $headers; + ksort($sortedHeaders); + + // Build headers keys and headers lines + $signedHeaderNames = []; + $signedHeaderLines = []; + + foreach ($sortedHeaders as $key => $value) { + $signedHeaderNames[] = strtolower($key); + $signedHeaderLines[] = sprintf('%s:%s', strtolower($key), $value); + } + + $signedHeaderLines = implode("\n", $signedHeaderLines); + $signedHeaderNames = implode(';', $signedHeaderNames); + + // Scope + $credentialScope = sprintf('%s/%s/%s/aws4_request', $this->date->format('Ymd'), $this->region, $service); + + // Canonical + $canonicalRequest = sprintf( + "%s\n%s\n%s\n%s\n\n%s\n%s", + $method, + $path, + $query, + $signedHeaderLines, + $signedHeaderNames, + $payloadHash + ); + + // Sign string + $hash = hash('sha256', $canonicalRequest); + $stringToSign = sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", $longDate, $credentialScope, $hash); + + // Sign key + $dateKey = hash_hmac('sha256', $this->date->format('Ymd'), sprintf('AWS4%s', $this->secretKey), true); + $regionKey = hash_hmac('sha256', $this->region, $dateKey, true); + $serviceKey = hash_hmac('sha256', 's3', $regionKey, true); + $signingKey = hash_hmac('sha256', 'aws4_request', $serviceKey, true); + $signature = hash_hmac('sha256', $stringToSign, $signingKey); + + // Compute together + return sprintf( + 'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s', + $this->accessKey, + $credentialScope, + $signedHeaderNames, + $signature + ); + } + +} diff --git a/src/Upload/S3Uploader.php b/src/Upload/S3Uploader.php new file mode 100644 index 0000000..798b12b --- /dev/null +++ b/src/Upload/S3Uploader.php @@ -0,0 +1,65 @@ +signer = $signer; + } + + /** + * @return array{url: string} + */ + public function upload(string $file): array + { + $filename = basename($file); + $signed = $this->signer->sign($filename); + + // Prepare vars + $headers = []; + foreach ($signed['headers'] as $key => $value) { + $headers[] = sprintf('%s:%s', $key, $value); + } + + $url = $signed['url']; + + // Read file + $content = file_get_contents($file); + + try { + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $content, + CURLOPT_HTTPHEADER => $headers, + ]); + + $response = curl_exec($curl); + + curl_close($curl); + } catch (Throwable $e) { + throw new UploadException('Cannot upload', 0, $e); + } + + if ($response !== true) { + throw new UploadException('Upload failed'); + } + + return ['url' => $url]; + } + +}