Files
hub/app/Http/Controllers/ImageController.php
kelly f652ccba90
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix: resolve Crystal's issues #19, #18, #17, #11, #8
Issue #19 - Product descriptions not showing in preview:
- Updated getStoryHtmlAttribute() to check consumer_long_description
  and buyer_long_description fields, not just long_description

Issues #18 & #17 - Product images not displaying after upload:
- Added new route /images/product-image/{productImage}/{width?}
- Added productImageById() method to ImageController
- Updated edit.blade.php and ProductImageController to use new route
- Each ProductImage now has its own unique URL instead of using product hashid

Issues #11 & #8 - LazyLoadingViolation when saving quotes/invoices:
- Removed auto-recalculate hooks from CrmQuoteItem and CrmInvoiceItem
- Controllers already call calculateTotals() explicitly after saving items
- Prevents lazy loading violations during item delete/create cycles
2025-12-18 09:46:48 -07:00

292 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Brand;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
/**
* Image Controller - Serves Brand Media from MinIO
*
* URL PATTERNS:
* =============
* - Logo: GET /images/brand-logo/{brand}/{width?}
* - Banner: GET /images/brand-banner/{brand}/{width?}
*
* Where:
* - {brand} = Brand hashid (e.g., "75pg7" for Aloha TymeMachine)
* - {width} = Optional thumbnail width in pixels (e.g., 160, 600, 1600)
*
* Examples:
* - /images/brand-logo/75pg7 → Original logo from MinIO
* - /images/brand-logo/75pg7/600 → 600px thumbnail (cached locally)
* - /images/brand-banner/75pg7/1344 → 1344px banner (cached locally)
*
* CRITICAL STORAGE RULES:
* =======================
*
* 1. ALWAYS use Storage (default disk) - NEVER Storage::disk('public')
* ✓ Storage::exists() → uses .env FILESYSTEM_DISK=minio
* ✗ Storage::disk('public')->exists() → bypasses .env, uses local disk
*
* 2. Brand assets MUST be on MinIO at:
* businesses/{business_slug}/brands/{brand_slug}/branding/{filename}
* Example: businesses/cannabrands/brands/thunder-bud/branding/logo.png
*
* 3. Thumbnails are cached to local disk for performance:
* storage/app/private/brands/cache/{brand_hashid}-{width}w.{ext}
*
* 4. Original images are fetched from MinIO and served directly:
* $contents = Storage::get($brand->logo_path); // Gets from MinIO
* return response($contents)->header('Content-Type', $mimeType);
*
* WHY THIS MATTERS:
* =================
* - MinIO is configured in .env as the default disk
* - All brand/product media lives on MinIO (S3-compatible storage)
* - Using Storage::disk('public') breaks images and violates architecture
* - This has caused multiple production issues - DO NOT change without review
*
* See: docs/architecture/MEDIA_STORAGE.md
*/
class ImageController extends Controller
{
/**
* Cache duration for images (1 year in seconds)
*/
private const CACHE_TTL = 31536000;
/**
* Return a cached response for an image
*/
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
{
$response = response($contents)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
if ($etag) {
$response->header('ETag', '"'.$etag.'"');
}
return $response;
}
/**
* Return a cached file response
*/
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
return response()->file($path, [
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
]);
}
/**
* Serve a brand logo at a specific size
* URL: /images/brand-logo/{brand}/{width?}
*/
public function brandLogo(Brand $brand, ?int $width = null)
{
if (! $brand->logo_path || ! Storage::exists($brand->logo_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($brand->logo_path);
$mimeType = Storage::mimeType($brand->logo_path);
$etag = md5($brand->logo_path.$brand->updated_at);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
$sizeNames = [
160 => 'thumb', // 2x retina for 80px display
600 => 'medium', // 2x retina for 300px display
1600 => 'large', // 2x retina for 800px display
];
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
$thumbnailName = $brand->hashid.'-'.$width.'w.'.$ext;
$thumbnailPath = 'brands/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk
$originalContents = Storage::get($brand->logo_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('brands/cache')) {
Storage::disk('local')->makeDirectory('brands/cache');
}
// Save as PNG or JPEG based on original format
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$path = storage_path('app/private/'.$thumbnailPath);
return $this->cachedFileResponse($path);
}
/**
* Serve a brand banner at a specific width
* URL: /images/brand-banner/{brand}/{width?}
*/
public function brandBanner(Brand $brand, ?int $width = null)
{
if (! $brand->banner_path || ! Storage::exists($brand->banner_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($brand->banner_path);
$mimeType = Storage::mimeType($brand->banner_path);
$etag = md5($brand->banner_path.$brand->updated_at);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
$sizeNames = [
1344 => 'medium', // 2x retina for 672px display
2560 => 'large', // 2x retina for 1280px display
];
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($brand->banner_path, PATHINFO_EXTENSION);
$thumbnailName = $brand->hashid.'-banner-'.$width.'w.'.$ext;
$thumbnailPath = 'brands/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk (MinIO)
$originalContents = Storage::get($brand->banner_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('brands/cache')) {
Storage::disk('local')->makeDirectory('brands/cache');
}
Storage::disk('local')->put($thumbnailPath, $image->toJpeg(quality: 90));
}
$path = storage_path('app/private/'.$thumbnailPath);
return $this->cachedFileResponse($path);
}
/**
* Serve a product image at a specific width
* URL: /images/product/{product}/{width?}
*/
public function productImage(\App\Models\Product $product, ?int $width = null)
{
if (! $product->image_path || ! Storage::exists($product->image_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($product->image_path);
$mimeType = Storage::mimeType($product->image_path);
$etag = md5($product->image_path.$product->updated_at);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($product->image_path, PATHINFO_EXTENSION);
$thumbnailName = $product->hashid.'-'.$width.'w.'.$ext;
$thumbnailPath = 'products/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk (MinIO)
$originalContents = Storage::get($product->image_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('products/cache')) {
Storage::disk('local')->makeDirectory('products/cache');
}
// Save as PNG or JPEG based on original format
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$path = storage_path('app/private/'.$thumbnailPath);
return $this->cachedFileResponse($path);
}
/**
* Serve a product image from the product_images table by ID
* URL: /images/product-image/{productImage}/{width?}
*/
public function productImageById(\App\Models\ProductImage $productImage, ?int $width = null)
{
if (! $productImage->path || ! Storage::exists($productImage->path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($productImage->path);
$mimeType = Storage::mimeType($productImage->path);
$etag = md5($productImage->path.$productImage->updated_at);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($productImage->path, PATHINFO_EXTENSION);
$thumbnailName = 'pi-'.$productImage->id.'-'.$width.'w.'.$ext;
$thumbnailPath = 'products/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk (MinIO)
$originalContents = Storage::get($productImage->path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('products/cache')) {
Storage::disk('local')->makeDirectory('products/cache');
}
// Save as PNG or JPEG based on original format
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$path = storage_path('app/private/'.$thumbnailPath);
return $this->cachedFileResponse($path);
}
}