All checks were successful
ci/woodpecker/push/ci Pipeline was successful
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
292 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|