diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 3e0cd4c..7866aa1 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -7,6 +7,7 @@ use App\Models\QrDocument; use App\Models\QrWallet; use App\Models\User; use App\Support\Qr\QrStyleDefaults; +use App\Support\Qr\UploadedImageOptimizer; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -425,7 +426,7 @@ class QrCodeManagerService $subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items'; $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); $sections[$sIndex]['items'][$iIndex]['image_path'] = $path; } } @@ -520,7 +521,7 @@ class QrCodeManagerService $uuid = Str::uuid()->toString(); $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/images/' . $uuid . '.' . $ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); $stored[] = [ 'path' => $path, 'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)), @@ -539,7 +540,7 @@ class QrCodeManagerService $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); return $path; } @@ -572,7 +573,7 @@ class QrCodeManagerService $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); return $path; } @@ -600,7 +601,7 @@ class QrCodeManagerService } $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); $items[$index][$pathKey] = $path; } @@ -616,7 +617,7 @@ class QrCodeManagerService $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext; - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); return $path; } @@ -629,7 +630,7 @@ class QrCodeManagerService } $path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png'); - $file->storeAs('', $path, 'qr'); + UploadedImageOptimizer::store($file, $path, 'qr'); return $path; } diff --git a/app/Support/Qr/UploadedImageOptimizer.php b/app/Support/Qr/UploadedImageOptimizer.php new file mode 100644 index 0000000..08eaf61 --- /dev/null +++ b/app/Support/Qr/UploadedImageOptimizer.php @@ -0,0 +1,162 @@ +storeAs('', $path, $disk); + + return false; + } + + Storage::disk($disk)->put($path, $optimised); + + return true; + } + + /** + * Re-encoded image bytes, or null when the file should be stored as-is. + */ + public static function encode(UploadedFile $file): ?string + { + if (! extension_loaded('gd')) { + return null; + } + + $maxDimension = (int) config('qr.image.max_dimension', 2000); + $quality = (int) config('qr.image.jpeg_quality', 82); + + try { + $realPath = $file->getRealPath(); + if ($realPath === false || ! is_readable($realPath)) { + return null; + } + + $info = @getimagesize($realPath); + if ($info === false) { + return null; // Not a raster image GD understands (e.g. SVG). + } + + [$width, $height, $type] = $info; + + if (! in_array($type, [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { + // GIF excluded on purpose: GD would drop animation frames. + return null; + } + + // Already small enough and not a heavyweight file: leave it alone rather + // than lossily re-encoding something that costs nothing to serve. + $longEdge = max($width, $height); + if ($longEdge <= $maxDimension && $file->getSize() <= (int) config('qr.image.passthrough_bytes', 512000)) { + return null; + } + + $source = match ($type) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($realPath), + IMAGETYPE_PNG => @imagecreatefrompng($realPath), + IMAGETYPE_WEBP => @imagecreatefromwebp($realPath), + }; + + if (! $source) { + return null; + } + + $target = self::resize($source, $width, $height, $maxDimension, $type); + + ob_start(); + $ok = match ($type) { + IMAGETYPE_JPEG => imagejpeg($target, null, $quality), + // PNG 6 is a size/CPU compromise; PNG re-encode is lossless. + IMAGETYPE_PNG => imagepng($target, null, 6), + IMAGETYPE_WEBP => imagewebp($target, null, $quality), + }; + $bytes = (string) ob_get_clean(); + + imagedestroy($source); + if ($target !== $source) { + imagedestroy($target); + } + + if (! $ok || $bytes === '') { + return null; + } + + // Never make a file bigger than it started. + return strlen($bytes) < $file->getSize() ? $bytes : null; + } catch (Throwable $e) { + Log::warning('image optimisation failed, storing original', [ + 'file' => $file->getClientOriginalName(), + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @param \GdImage $source + * @return \GdImage + */ + private static function resize($source, int $width, int $height, int $maxDimension, int $type) + { + $longEdge = max($width, $height); + if ($longEdge <= $maxDimension) { + return $source; + } + + $scale = $maxDimension / $longEdge; + $newWidth = max(1, (int) round($width * $scale)); + $newHeight = max(1, (int) round($height * $scale)); + + $target = imagecreatetruecolor($newWidth, $newHeight); + + if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) { + // Keep alpha; without this transparent areas come back black. + imagealphablending($target, false); + imagesavealpha($target, true); + $transparent = imagecolorallocatealpha($target, 0, 0, 0, 127); + imagefilledrectangle($target, 0, 0, $newWidth, $newHeight, $transparent); + } + + imagecopyresampled($target, $source, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); + + return $target; + } +} diff --git a/config/qr.php b/config/qr.php index 50706a9..9538a99 100644 --- a/config/qr.php +++ b/config/qr.php @@ -4,6 +4,19 @@ return [ 'price_per_qr_ghs' => (float) env('QR_PRICE_PER_QR_GHS', 5.0), 'min_topup_ghs' => (float) env('QR_MIN_TOPUP_GHS', 5.0), 'max_pdf_bytes' => (int) env('QR_MAX_PDF_BYTES', 104857600), // 100 MB + + /* + | Uploaded images are downscaled and re-encoded before storage. These assets + | are proxied through PHP to public bio pages, so an oversized original costs + | a worker and full bandwidth on every single view — a 9000x6600 18MB cover + | once accounted for ~1.6GB of 1.9GB of image traffic during a launch. + */ + 'image' => [ + 'max_dimension' => (int) env('QR_IMAGE_MAX_DIMENSION', 2000), + 'jpeg_quality' => (int) env('QR_IMAGE_JPEG_QUALITY', 82), + // Small files are stored untouched rather than lossily re-encoded. + 'passthrough_bytes' => (int) env('QR_IMAGE_PASSTHROUGH_BYTES', 512000), + ], 'short_code_length' => (int) env('QR_SHORT_CODE_LENGTH', 8), 'scan_unique_window_hours' => (int) env('QR_SCAN_UNIQUE_WINDOW_HOURS', 24), ]; diff --git a/tests/Feature/UploadedImageOptimizerTest.php b/tests/Feature/UploadedImageOptimizerTest.php new file mode 100644 index 0000000..2aa2904 --- /dev/null +++ b/tests/Feature/UploadedImageOptimizerTest.php @@ -0,0 +1,135 @@ + 2000, 'qr.image.jpeg_quality' => 82]); + } + + /** Write a real JPEG of the given size to a temp file and wrap it as an upload. */ + private function jpeg(int $width, int $height): UploadedFile + { + $img = imagecreatetruecolor($width, $height); + // Some actual detail, so the encoder cannot trivially compress it to nothing. + for ($i = 0; $i < 60; $i++) { + $colour = imagecolorallocate($img, random_int(0, 255), random_int(0, 255), random_int(0, 255)); + imagefilledrectangle( + $img, + random_int(0, $width - 1), random_int(0, $height - 1), + random_int(0, $width - 1), random_int(0, $height - 1), + $colour, + ); + } + $path = tempnam(sys_get_temp_dir(), 'img').'.jpg'; + imagejpeg($img, $path, 92); + imagedestroy($img); + + return new UploadedFile($path, 'cover.jpg', 'image/jpeg', null, true); + } + + private function png(int $width, int $height): UploadedFile + { + $img = imagecreatetruecolor($width, $height); + imagealphablending($img, false); + imagesavealpha($img, true); + imagefilledrectangle($img, 0, 0, $width, $height, imagecolorallocatealpha($img, 0, 0, 0, 127)); + imagefilledrectangle($img, 10, 10, (int) ($width / 2), (int) ($height / 2), imagecolorallocate($img, 255, 0, 0)); + $path = tempnam(sys_get_temp_dir(), 'img').'.png'; + imagepng($img, $path); + imagedestroy($img); + + return new UploadedFile($path, 'logo.png', 'image/png', null, true); + } + + public function test_an_oversized_cover_is_downscaled_and_shrunk(): void + { + // The shape of the file that caused the incident: print resolution. + $file = $this->jpeg(4000, 3000); + $originalBytes = $file->getSize(); + + UploadedImageOptimizer::store($file, '1/bookshop-covers/cover.jpg'); + + $stored = Storage::disk('qr')->get('1/bookshop-covers/cover.jpg'); + $this->assertNotNull($stored); + + [$w, $h] = getimagesizefromstring($stored); + $this->assertSame(2000, $w, 'Long edge should be capped at max_dimension.'); + $this->assertSame(1500, $h, 'Aspect ratio must be preserved.'); + $this->assertLessThan($originalBytes, strlen($stored)); + } + + public function test_a_small_image_is_stored_untouched(): void + { + $file = $this->jpeg(400, 300); + $originalBytes = $file->getSize(); + + $optimised = UploadedImageOptimizer::store($file, '1/logos/small.jpg'); + + $this->assertFalse($optimised, 'Small files should not be re-encoded.'); + $this->assertSame($originalBytes, strlen(Storage::disk('qr')->get('1/logos/small.jpg'))); + } + + public function test_png_transparency_survives_resizing(): void + { + $file = $this->png(3000, 3000); + + UploadedImageOptimizer::store($file, '1/logos/logo.png'); + $stored = Storage::disk('qr')->get('1/logos/logo.png'); + + $img = imagecreatefromstring($stored); + $this->assertNotFalse($img); + [$w] = getimagesizefromstring($stored); + $this->assertSame(2000, $w); + + // Top-left was fully transparent; it must not have become black. + $alpha = (imagecolorat($img, 1, 1) >> 24) & 0x7F; + imagedestroy($img); + $this->assertGreaterThan(0, $alpha, 'Alpha channel was lost during resize.'); + } + + public function test_format_is_preserved(): void + { + UploadedImageOptimizer::store($this->png(3000, 3000), '1/logos/keep.png'); + + $info = getimagesizefromstring(Storage::disk('qr')->get('1/logos/keep.png')); + $this->assertSame(IMAGETYPE_PNG, $info[2], 'A PNG must not be converted to JPEG.'); + } + + public function test_a_non_image_is_stored_unchanged(): void + { + $file = UploadedFile::fake()->create('book.pdf', 40, 'application/pdf'); + + $optimised = UploadedImageOptimizer::store($file, '1/books/book.pdf'); + + $this->assertFalse($optimised); + $this->assertTrue(Storage::disk('qr')->exists('1/books/book.pdf')); + } + + public function test_the_incident_file_shape_drops_below_a_sane_size(): void + { + // 9000x6600 was the real cover; assert the outcome we actually needed. + $file = $this->jpeg(9000, 6600); + + UploadedImageOptimizer::store($file, '1/bookshop-covers/huge.jpg'); + $stored = Storage::disk('qr')->get('1/bookshop-covers/huge.jpg'); + + [$w, $h] = getimagesizefromstring($stored); + $this->assertSame(2000, $w); + $this->assertSame(1467, $h); + $this->assertLessThan( + 1_500_000, + strlen($stored), + 'A bio-page cover served to every visitor must not be megabytes.', + ); + } +}