Compare commits

...

2 Commits

Author SHA1 Message Date
Kelly
54788efd9e feat: migrate product edit page to new top header layout with image zoom
- Updated product edit page to match new DaisyUI Monochrome design
- Fixed sidebar image display using asset('storage/') pattern
- Added click-to-zoom modal for product images using DaisyUI dialog
- Fixed monochrome violations on products index page
- Updated active products partial to use correct image display pattern
- All styling uses DaisyUI/Tailwind classes (no inline styles)
2025-11-07 01:26:56 -07:00
Kelly
825f4fcf30 feat: add MinIO S3 storage integration with environment-aware configuration
Add support for MinIO S3-compatible object storage with automatic environment
switching between local filesystem and CDN storage.

Changes:
- Update .env.example with MinIO configuration for dev/prod environments
- Add default region fallback to S3 filesystem configuration
- Create comprehensive FILE_STORAGE.md documentation
- Create MINIO_SECURITY_SETUP.md with security policies and setup guide

Environment Configuration:
- Local (APP_ENV=local): Uses local filesystem storage (storage/app/private)
- Development (APP_ENV=development): Uses MinIO media-dev bucket
- Production (APP_ENV=production): Uses MinIO media bucket

Security:
- Public read access for CDN functionality
- Credential-based write access via Access Keys
- Simple bucket policies without IP restrictions for better compatibility
2025-11-06 17:17:29 -07:00
10 changed files with 905 additions and 45 deletions

View File

@@ -19,7 +19,8 @@
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
"Bash(php check_blade.php:*)",
"Bash(git stash:*)"
],
"deny": [],
"ask": []

View File

@@ -34,6 +34,10 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
# File Storage Configuration:
# - Local (APP_ENV=local): FILESYSTEM_DISK=local (default, stores in storage/app/private)
# - Development (APP_ENV=development): FILESYSTEM_DISK=s3 (uses MinIO media-dev bucket)
# - Production (APP_ENV=production): FILESYSTEM_DISK=s3 (uses MinIO media bucket)
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
@@ -78,8 +82,9 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# AWS/MinIO S3 Storage Configuration
# Local development: Use FILESYSTEM_DISK=public (default)
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
# Local (APP_ENV=local): Use FILESYSTEM_DISK=local (default, no MinIO needed)
# Development (APP_ENV=development): Use FILESYSTEM_DISK=s3 with media-dev bucket (see below)
# Production (APP_ENV=production): Use FILESYSTEM_DISK=s3 with media bucket (configure in production .env)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
@@ -88,14 +93,14 @@ AWS_ENDPOINT=
AWS_URL=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production MinIO Configuration (example):
# To test MinIO locally, uncomment these (development bucket credentials):
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_ACCESS_KEY_ID=G7iOnLEjjdr47vaWHKSR
# AWS_SECRET_ACCESS_KEY=qxLw3M3IVhvzGU0NoSZQ8BwKoxlkloVAp8GO8dKP
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_BUCKET=media-dev
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_URL=https://cdn.cannabrands.app/media-dev
# AWS_USE_PATH_STYLE_ENDPOINT=true
VITE_APP_NAME="${APP_NAME}"

View File

@@ -191,6 +191,9 @@ class ProductController extends Controller
'discontinued' => 'Discontinued',
];
// Get audit history for the product
$audits = $product->audits()->with('user')->orderBy('created_at', 'desc')->paginate(10);
return view('seller.products.edit', compact(
'business',
'product',
@@ -200,7 +203,8 @@ class ProductController extends Controller
'units',
'productLines',
'productTypes',
'statusOptions'
'statusOptions',
'audits'
));
}
@@ -402,7 +406,7 @@ class ProductController extends Controller
$product->update($validated);
return redirect()
->route('seller.business.products.edit', [$business->slug, $product->id])
->route('seller.business.products.edit', [$business->slug, $product->hashid])
->with('success', 'Product updated successfully!');
}

View File

@@ -51,7 +51,7 @@ return [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),

417
docs/FILE_STORAGE.md Normal file
View File

@@ -0,0 +1,417 @@
# File Storage Documentation
## Overview
This application uses environment-aware file storage that automatically switches between local storage and MinIO S3 CDN based on the `FILESYSTEM_DISK` environment variable.
## Environment Configuration
The storage system automatically switches based on the `APP_ENV` and `FILESYSTEM_DISK` environment variables:
| `APP_ENV` | `FILESYSTEM_DISK` | Storage Backend | Bucket/Location |
|-----------|-------------------|-----------------|-----------------|
| `local` | `local` (default) | Local filesystem | `storage/app/private` |
| `development` | `s3` | MinIO S3 CDN | `media-dev` bucket |
| `production` | `s3` | MinIO S3 CDN | `media` bucket |
### Local Development (Your Machine)
```env
APP_ENV=local
FILESYSTEM_DISK=local
```
- Files stored in: `storage/app/private`
- Public files symlinked via: `storage/app/public``public/storage`
- **No MinIO required** - everything stays on your local machine
- Run: `php artisan storage:link` to create the public symlink
### Development Environment (Deployed Server)
```env
APP_ENV=development
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=G7iOnLEjjdr47vaWHKSR
AWS_SECRET_ACCESS_KEY=qxLw3M3IVhvzGU0NoSZQ8BwKoxlkloVAp8GO8dKP
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=media-dev
AWS_ENDPOINT=https://cdn.cannabrands.app
AWS_URL=https://cdn.cannabrands.app/media-dev
AWS_USE_PATH_STYLE_ENDPOINT=true
```
- Files stored in: MinIO `media-dev` bucket
- Accessed via: `https://cdn.cannabrands.app/media-dev/...`
### Production Environment (Deployed Server)
```env
APP_ENV=production
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=gt7caNY6jTIRektJKa70
AWS_SECRET_ACCESS_KEY=vJeokgbHlGzOXnE0QUzpvETRvs1cpFgeUEZ2xweq
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=media
AWS_ENDPOINT=https://cdn.cannabrands.app
AWS_URL=https://cdn.cannabrands.app/media
AWS_USE_PATH_STYLE_ENDPOINT=true
```
- Files stored in: MinIO `media` bucket
- Accessed via: `https://cdn.cannabrands.app/media/...`
## Using FileStorageHelper Trait
### Adding the Trait to Your Model
```php
<?php
namespace App\Models;
use App\Traits\FileStorageHelper;
use Illuminate\Database\Eloquent\Model;
class Brand extends Model
{
use FileStorageHelper;
// ... rest of model code
}
```
### Example: Uploading a Brand Logo
#### In Your Controller
```php
<?php
namespace App\Http\Controllers\Seller;
use App\Models\Brand;
use Illuminate\Http\Request;
class BrandController extends Controller
{
public function updateLogo(Request $request, Brand $brand)
{
$request->validate([
'logo' => 'required|image|mimes:jpeg,png,jpg,svg|max:2048',
]);
$file = $request->file('logo');
// Option 1: Auto-generate filename
$path = $brand->storeFile($file, "brands/{$brand->hashid}/logos");
// Option 2: Custom filename
$filename = $brand->generateUniqueFilename($file->getClientOriginalName());
$path = $brand->storeFile($file, "brands/{$brand->hashid}/logos", $filename);
// Option 3: Replace existing logo (deletes old, uploads new)
$path = $brand->replaceFile(
$file,
$brand->logo_path, // old file path
"brands/{$brand->hashid}/logos"
);
// Update model
$brand->update(['logo_path' => $path]);
return redirect()->back()->with('success', 'Logo updated successfully!');
}
}
```
#### In Your Blade View
```blade
<form action="{{ route('seller.brands.logo.update', $brand) }}"
method="POST"
enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Current Logo Preview -->
@if($brand->logo_path)
<div class="mb-4">
<img src="{{ $brand->getFileUrl($brand->logo_path) }}"
alt="{{ $brand->name }} Logo"
class="h-20 w-20 object-contain">
</div>
@endif
<!-- Upload Input -->
<input type="file"
name="logo"
accept="image/*"
class="file-input file-input-bordered w-full">
<button type="submit" class="btn btn-primary mt-4">
Upload Logo
</button>
</form>
```
### Example: Product Images (Multiple Files)
#### Controller
```php
<?php
namespace App\Http\Controllers\Seller;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductImageController extends Controller
{
public function store(Request $request, Product $product)
{
$request->validate([
'images' => 'required|array|min:1|max:10',
'images.*' => 'required|image|mimes:jpeg,png,jpg|max:5120',
]);
$uploadedPaths = [];
foreach ($request->file('images') as $file) {
// Store with unique filename
$filename = $product->generateUniqueFilename($file->getClientOriginalName());
$path = $product->storeFile(
$file,
"products/{$product->brand->hashid}/{$product->id}/images",
$filename
);
$uploadedPaths[] = $path;
// Create ProductImage record
$product->images()->create([
'file_path' => $path,
'sort_order' => $product->images()->count(),
]);
}
return response()->json([
'success' => true,
'message' => count($uploadedPaths) . ' images uploaded successfully',
'paths' => $uploadedPaths,
]);
}
public function destroy(Product $product, ProductImage $image)
{
// Delete from storage
$product->deleteFile($image->file_path);
// Delete record
$image->delete();
return response()->json(['success' => true]);
}
}
```
#### Blade View (with Alpine.js)
```blade
<div x-data="imageUploader()">
<!-- Upload Area -->
<div class="border-2 border-dashed border-base-300 rounded-lg p-6">
<input type="file"
multiple
accept="image/*"
@change="uploadImages($event)"
class="file-input file-input-bordered w-full">
</div>
<!-- Image Gallery -->
<div class="grid grid-cols-4 gap-4 mt-6">
@foreach($product->images as $image)
<div class="relative group">
<img src="{{ $product->getFileUrl($image->file_path) }}"
alt="Product Image"
class="w-full h-40 object-cover rounded-lg">
<button @click="deleteImage({{ $image->id }})"
class="absolute top-2 right-2 btn btn-sm btn-circle btn-error opacity-0 group-hover:opacity-100">
</button>
</div>
@endforeach
</div>
</div>
<script>
function imageUploader() {
return {
async uploadImages(event) {
const formData = new FormData();
const files = event.target.files;
for (let i = 0; i < files.length; i++) {
formData.append('images[]', files[i]);
}
try {
const response = await fetch('{{ route("seller.products.images.store", $product) }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: formData
});
const data = await response.json();
if (data.success) {
window.location.reload();
}
} catch (error) {
console.error('Upload failed:', error);
}
},
async deleteImage(imageId) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/s/products/images/${imageId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
window.location.reload();
}
} catch (error) {
console.error('Delete failed:', error);
}
}
}
}
</script>
```
## Available FileStorageHelper Methods
### Core Methods
```php
// Get current storage disk name ('public' or 's3')
$disk = $model->getStorageDisk();
// Check if using S3/MinIO
$isS3 = $model->isUsingS3();
// Store a file
$path = $model->storeFile($uploadedFile, 'folder/path');
$path = $model->storeFile($uploadedFile, 'folder/path', 'custom-name.jpg');
// Generate unique filename
$filename = $model->generateUniqueFilename('original-name.jpg');
// Get public URL (works for both local and CDN)
$url = $model->getFileUrl($path);
// Delete file
$deleted = $model->deleteFile($path);
// Replace file (delete old, upload new)
$path = $model->replaceFile($newFile, $oldPath, 'folder/path');
// Get storage info (for debugging)
$info = $model->getStorageInfo();
```
## File Organization Structure
### Brand Assets
```
brands/{brand-hashid}/
├── logos/
│ └── logo-abc123.png
├── banners/
│ └── banner-xyz789.jpg
└── documents/
└── license-cert.pdf
```
### Product Assets
```
products/{brand-hashid}/{product-id}/
├── images/
│ ├── main-1635789234-abc123.jpg
│ ├── gallery-1635789245-def456.jpg
│ └── gallery-1635789256-ghi789.jpg
└── documents/
└── lab-test-2023-Q4.pdf
```
## Important Notes
1. **Always use FileStorageHelper methods** - Don't call `Storage::disk('public')` directly in models/controllers
2. **The trait handles environment switching automatically** - Same code works in dev and production
3. **URLs are environment-aware** - `getFileUrl()` returns correct URL for current environment
4. **File paths are relative** - Store paths without disk prefix (e.g., `brands/52kn5/logo.png`, not `/storage/brands/...`)
5. **Test in both environments** - Verify uploads work locally before deploying
## Migration Guide for Existing Code
### Before (Hard-coded disk):
```php
// ❌ Don't do this
$path = $request->file('logo')->store('brands', 'public');
$url = asset('storage/' . $path);
Storage::disk('public')->delete($oldPath);
```
### After (Using FileStorageHelper):
```php
// ✅ Do this instead
use App\Traits\FileStorageHelper;
class Brand extends Model
{
use FileStorageHelper;
}
// In controller:
$path = $brand->storeFile($request->file('logo'), 'brands');
$url = $brand->getFileUrl($path);
$brand->deleteFile($oldPath);
```
## Debugging Storage Issues
```php
// Check current configuration
dd(config('filesystems.default')); // 'public' or 's3'
// Get detailed storage info
$brand = Brand::first();
dd($brand->getStorageInfo());
// Expected output:
[
'disk' => 's3',
'is_s3' => true,
'driver' => 's3',
'endpoint' => 'https://cdn.cannabrands.app',
'bucket' => 'media',
]
```
## Testing
```php
// In your tests, you can mock storage
use Illuminate\Support\Facades\Storage;
Storage::fake('public');
// ... test file upload ...
Storage::disk('public')->assertExists($path);
```

View File

@@ -0,0 +1,361 @@
# MinIO Security Setup
## Overview
This document outlines the security implementation for MinIO S3-compatible storage used by Cannabrands Hub in **deployed environments only**.
## Environment Storage Configuration
| Environment | `FILESYSTEM_DISK` | Storage Backend | MinIO Usage |
|-------------|-------------------|-----------------|-------------|
| Local Development | `public` | `storage/app/public` | ❌ Not used |
| Development/Staging (deployed) | `s3` | MinIO `media-dev` bucket | ✅ Used |
| Production (deployed) | `s3` | MinIO `media` bucket | ✅ Used |
---
## Security Requirements
### IP Whitelist Restrictions
MinIO buckets should only be accessible from **deployed server IPs** to prevent unauthorized access.
**IPs to Whitelist:**
- ✅ **Development/Staging Server IP**: `<DEV_SERVER_IP>` (where dev environment is deployed)
- ✅ **Production Server IP**: `<PROD_SERVER_IP>` (where production is deployed)
- ✅ **CI/CD Pipeline IP**: `<CI_CD_IP>` (if running tests/builds that need MinIO access)
**NOT needed:**
- ❌ Local developer machines (they use local storage)
---
## Bucket Policies with IP Restrictions
### Production Bucket Policy (`media`)
This policy allows:
- ✅ **Anyone** can read files (public CDN access)
- ✅ **Only production server IP** can write/delete files
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadAccess",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::media/*"]
},
{
"Sid": "ProductionServerWriteAccess",
"Effect": "Allow",
"Principal": {
"AWS": ["arn:aws:iam::*:user/cannabrands-production"]
},
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::media",
"arn:aws:s3:::media/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"<PRODUCTION_SERVER_IP>/32"
]
}
}
}
]
}
```
---
### Development Bucket Policy (`media-dev`)
This policy allows:
- ✅ **Anyone** can read files (public CDN access for testing)
- ✅ **Only dev/staging server IPs** can write/delete files
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadAccess",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::media-dev/*"]
},
{
"Sid": "DevServerWriteAccess",
"Effect": "Allow",
"Principal": {
"AWS": ["arn:aws:iam::*:user/cannabrands-development"]
},
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::media-dev",
"arn:aws:s3:::media-dev/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"<DEV_SERVER_IP>/32",
"<CI_CD_IP>/32"
]
}
}
}
]
}
```
---
## MinIO User Policies
### Production User Policy (`cannabrands-production-policy`)
Attach to access key: `gt7caNY6jTIRektJKa70`
**IMPORTANT:** Access Key policies should NOT have IP restrictions - those belong in bucket policies only!
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": ["arn:aws:s3:::media"]
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": ["arn:aws:s3:::media/*"]
}
]
}
```
### Development User Policy (`cannabrands-development-policy`)
Attach to access key: `G7iOnLEjjdr47vaWHKSR`
**IMPORTANT:** Access Key policies should NOT have IP restrictions - those belong in bucket policies only!
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": ["arn:aws:s3:::media-dev"]
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": ["arn:aws:s3:::media-dev/*"]
}
]
}
```
---
## Additional Security Features
### 1. Bucket Versioning (Production Only)
Prevent accidental data loss:
```bash
mc version enable myminio/media
```
### 2. Server-Side Encryption
Encrypt data at rest:
```bash
mc encrypt set sse-s3 myminio/media
mc encrypt set sse-s3 myminio/media-dev
```
### 3. Object Lifecycle Policies
Auto-cleanup temporary files:
```json
{
"Rules": [
{
"ID": "DeleteTempFiles",
"Status": "Enabled",
"Filter": {"Prefix": "temp/"},
"Expiration": {"Days": 7}
},
{
"ID": "DeleteTestFiles",
"Status": "Enabled",
"Filter": {"Prefix": "tests/"},
"Expiration": {"Days": 1}
}
]
}
```
### 4. CORS Configuration (If Needed for Direct Browser Uploads)
```xml
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://cannabrands.app</AllowedOrigin>
<AllowedOrigin>https://dev.cannabrands.app</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
```
---
## Implementation Checklist
Access MinIO Admin Console at: **https://cdn.cannabrands.app:9001** (or :9000)
### Phase 1: User Policies (Do This First)
- [ ] Create user policy `cannabrands-production-policy`
- [ ] Attach policy to user with access key `gf7caNY6jTIRektJKa70`
- [ ] Create user policy `cannabrands-development-policy`
- [ ] Attach policy to user with access key `G7iOnLEjidr47vaWHKSR`
### Phase 2: Get Server IPs
- [ ] Get production server public IP address
- [ ] Get development/staging server public IP address
- [ ] Get CI/CD pipeline IP (if applicable)
### Phase 3: Bucket Policies (Do This After Getting IPs)
- [ ] Update `media` bucket policy with production server IP
- [ ] Update `media-dev` bucket policy with dev server IP + CI/CD IP
### Phase 4: Security Enhancements
- [ ] Enable versioning on `media` bucket
- [ ] Enable encryption on both buckets
- [ ] Set up lifecycle policies for temp/test files
- [ ] Configure CORS if needed
### Phase 5: Testing
- [ ] Test from production server: `php artisan tinker``Storage::disk('s3')->exists('test.txt')`
- [ ] Test from dev server: Same command
- [ ] Test public CDN access: `curl https://cdn.cannabrands.app/media/test-file.jpg`
- [ ] Verify local development still works with `FILESYSTEM_DISK=public`
---
## Quick Start (TL;DR)
1. **Log into MinIO admin console**: https://cdn.cannabrands.app:9001
2. **Navigate to**: Identity → Users
3. **Find user with access key** `gf7caNY6jTIRektJKa70`
4. **Create and attach policy** (see Production User Policy above)
5. **Repeat for** development key `G7iOnLEjidr47vaWHKSR`
6. **Get your server IPs** from your hosting provider
7. **Update bucket policies** with the server IPs (see Bucket Policies above)
8. **Test from deployed servers**, not local machine
---
## Troubleshooting
### Error: "InvalidAccessKeyId"
**Cause:** IP whitelist in bucket policy is blocking the request before credentials are checked.
**Fix:** Add the deployed server's IP to the bucket policy.
### Error: "Access Denied" (after authentication works)
**Cause:** User policy doesn't grant necessary permissions.
**Fix:** Verify the user policy includes `s3:ListBucket`, `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject`.
### Local Development Issues
**Remember:** Local development should use `FILESYSTEM_DISK=public` and NOT touch MinIO at all.
If you see MinIO errors locally, check your `.env` file and ensure:
```env
FILESYSTEM_DISK=public # NOT s3
```
---
## What You Need from Infrastructure Team
If someone else manages the MinIO server, send them this:
**Subject:** MinIO Bucket Policy Update Needed for Cannabrands Hub
**Body:**
```
Hi,
We need the following updates to our MinIO configuration:
1. User Policies:
- Create policy "cannabrands-production-policy" (see attached JSON)
- Attach to access key: gf7caNY6jTIRektJKa70
- Create policy "cannabrands-development-policy" (see attached JSON)
- Attach to access key: G7iOnLEjidr47vaWHKSR
2. Bucket IP Whitelist (will provide IPs after deployment):
- media bucket: Allow access from <PROD_SERVER_IP>
- media-dev bucket: Allow access from <DEV_SERVER_IP> and <CI_CD_IP>
3. Additional security features (nice to have):
- Enable versioning on "media" bucket
- Enable SSE-S3 encryption on both buckets
See attached docs/MINIO_SECURITY_SETUP.md for full details.
Thanks!
```

View File

@@ -3,7 +3,7 @@
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Top Header Bar --}}
<div class="bg-white border border-gray-300 rounded-md shadow-sm mb-6">
<div class="bg-base-100 border border-base-300 rounded-md shadow-sm mb-6">
<div class="px-6 py-4">
<div class="flex items-start justify-between gap-6">
{{-- Left: Product Image & Info --}}
@@ -13,10 +13,10 @@
@if($product->images->where('is_primary', true)->first())
<img src="{{ asset('storage/' . $product->images->where('is_primary', true)->first()->path) }}"
alt="{{ $product->name }}"
class="w-16 h-16 object-cover rounded-md border border-gray-300">
class="w-16 h-16 object-cover rounded-md border border-base-300">
@else
<div class="w-16 h-16 bg-gray-100 rounded-md border border-gray-300 flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-16 h-16 bg-base-200 rounded-md border border-base-300 flex items-center justify-center">
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
@@ -26,17 +26,17 @@
{{-- Product Details --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h1 class="text-xl font-bold text-gray-900">{{ $product->name }}</h1>
<h1 class="text-xl font-bold text-base-content">{{ $product->name }}</h1>
{{-- Status Badges --}}
<span id="activeBadge" style="display: {{ $product->is_active ? 'inline-flex' : 'none' }};" class="items-center px-2.5 py-0.5 rounded text-xs font-medium bg-success text-white">
Active
</span>
<span id="featuredBadge" style="display: {{ $product->is_featured ? 'inline-flex' : 'none' }};" class="items-center px-2.5 py-0.5 rounded text-xs font-medium bg-warning text-white">
Featured
</span>
@if($product->is_active)
<span class="badge badge-success badge-sm">Active</span>
@endif
@if($product->is_featured)
<span class="badge badge-warning badge-sm">Featured</span>
@endif
</div>
<div class="text-sm text-gray-600 space-y-0.5">
<div><span class="font-medium">SKU:</span> <span class="font-mono">{{ $product->sku ?? 'N/A' }}</span> <span class="mx-2"></span> <span class="font-medium">Brand:</span> {{ $product->brand->name ?? 'N/A' }}</div>
<div class="text-sm text-base-content/60 space-y-0.5">
<div><span class="font-medium">SKU:</span> <span class="font-mono text-xs">{{ $product->sku ?? 'N/A' }}</span> <span class="mx-2"></span> <span class="font-medium">Brand:</span> {{ $product->brand->name ?? 'N/A' }}</div>
<div><span class="font-medium">Last updated:</span> {{ $product->updated_at->format('M j, Y g:i A') }}</div>
</div>
</div>
@@ -45,7 +45,7 @@
{{-- Right: Action Buttons & Breadcrumb --}}
<div class="flex flex-col gap-2 flex-shrink-0">
{{-- View on Marketplace Button (White with border) --}}
<a href="#" target="_blank" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
<a href="#" target="_blank" class="inline-flex items-center justify-center px-4 py-2 border border-base-300 rounded-md text-sm font-medium text-base-content/70 bg-white hover:bg-base-200 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
@@ -53,7 +53,7 @@
</a>
{{-- Manage BOM Button (Blue solid) --}}
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors">
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->hashid]) }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
@@ -61,12 +61,12 @@
</a>
{{-- Breadcrumb Navigation --}}
<nav class="flex text-xs text-gray-500 mt-1" aria-label="Breadcrumb">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="hover:text-gray-700">Dashboard</a>
<nav class="flex text-xs text-base-content/50 mt-1" aria-label="Breadcrumb">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="hover:text-base-content/70">Dashboard</a>
<span class="mx-2">&gt;</span>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-base-content/70">Products</a>
<span class="mx-2">&gt;</span>
<span class="text-gray-900 font-medium">Edit</span>
<span class="text-base-content font-medium">Edit</span>
</nav>
</div>
</div>
@@ -96,13 +96,13 @@
</div>
@endif
<form method="POST" action="{{ route('seller.business.products.update', [$business->slug, $product->id]) }}" class="space-y-6" enctype="multipart/form-data">
<form method="POST" action="{{ route('seller.business.products.update', [$business->slug, $product->hashid]) }}" class="space-y-6" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{{-- LEFT SIDEBAR (1/4 width) --}}
<div class="space-y-6">
<div class="space-y-6 lg:sticky lg:top-6">
{{-- Product Images Card --}}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -112,9 +112,13 @@
$primaryImage = $product->images()->where('is_primary', true)->first();
@endphp
@if($primaryImage)
<img src="{{ Storage::url($primaryImage->path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
<button type="button" onclick="product_image_modal.showModal()" class="w-full rounded-lg overflow-hidden cursor-zoom-in hover:opacity-90 transition-opacity">
<img src="{{ asset('storage/' . $primaryImage->path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
</button>
@elseif($product->image_path)
<img src="{{ Storage::url($product->image_path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
<button type="button" onclick="product_image_modal.showModal()" class="w-full rounded-lg overflow-hidden cursor-zoom-in hover:opacity-90 transition-opacity">
<img src="{{ asset('storage/' . $product->image_path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
</button>
@else
<div class="aspect-square bg-base-200 rounded-lg flex items-center justify-center">
<span class="text-base-content/50">No image</span>
@@ -130,7 +134,7 @@
<button type="button"
onclick="setPrimaryImage({{ $image->id }})"
class="relative group aspect-square rounded-lg overflow-hidden border-2 transition-all {{ $image->is_primary ? 'border-primary ring-2 ring-primary ring-offset-2' : 'border-base-300 hover:border-primary' }}">
<img src="{{ Storage::url($image->path) }}" alt="Product image" class="w-full h-full object-cover">
<img src="{{ asset('storage/' . $image->path) }}" alt="Product image" class="w-full h-full object-cover">
@if($image->is_primary)
<div class="absolute top-0 right-0 m-1">
<span class="badge badge-primary badge-xs">Primary</span>
@@ -1357,7 +1361,7 @@
{{-- Action Buttons --}}
<div class="flex flex-col sm:flex-row sm:justify-end gap-3 mt-6 pt-6 border-t">
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors w-full sm:w-auto">
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="inline-flex items-center justify-center px-4 py-2 border border-base-300 rounded-md text-sm font-medium text-base-content/70 bg-white hover:bg-base-200 transition-colors w-full sm:w-auto">
Cancel
</a>
<button type="submit" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors w-full sm:w-auto">
@@ -1484,7 +1488,7 @@
formData.append('image', file);
formData.append('_token', document.querySelector('input[name="_token"]').value);
fetch('{{ route("seller.business.products.images.upload", [$business->slug, $product->id]) }}', {
fetch('{{ route("seller.business.products.images.upload", [$business->slug, $product->hashid]) }}', {
method: 'POST',
body: formData
})
@@ -1516,7 +1520,7 @@
deleteImage(imageId, index) {
if (!confirm('Delete this image?')) return;
fetch('/s/{{ $business->slug }}/products/{{ $product->id }}/images/' + imageId, {
fetch('/s/{{ $business->slug }}/products/{{ $product->hashid }}/images/' + imageId, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value,
@@ -1542,7 +1546,7 @@
const items = Array.from(grid.children);
const newOrder = items.map(item => parseInt(item.dataset.id)).filter(id => id);
fetch('{{ route("seller.business.products.images.reorder", [$business->slug, $product->id]) }}', {
fetch('{{ route("seller.business.products.images.reorder", [$business->slug, $product->hashid]) }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value,
@@ -1569,7 +1573,7 @@
// Set primary image from sidebar thumbnails
function setPrimaryImage(imageId) {
fetch('/s/{{ $business->slug }}/products/{{ $product->id }}/images/' + imageId + '/set-primary', {
fetch('/s/{{ $business->slug }}/products/{{ $product->hashid }}/images/' + imageId + '/set-primary', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value,
@@ -1590,4 +1594,25 @@
});
}
</script>
{{-- Image Zoom Modal --}}
<dialog id="product_image_modal" class="modal">
<div class="modal-box max-w-5xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4">{{ $product->name }}</h3>
@php
$primaryImage = $product->images()->where('is_primary', true)->first();
@endphp
@if($primaryImage)
<img src="{{ asset('storage/' . $primaryImage->path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
@elseif($product->image_path)
<img src="{{ asset('storage/' . $product->image_path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
@endif
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@endsection

View File

@@ -11,7 +11,7 @@
<p class="text-base-content/60 mt-1">
Manage your product catalog
@if(session('selected_brand_id'))
Viewing: <span class="badge badge-info badge-sm">{{ \App\Http\Controllers\Seller\BrandSwitcherController::getSelectedBrand()?->name }}</span>
Viewing: <span class="badge badge-primary badge-sm">{{ \App\Http\Controllers\Seller\BrandSwitcherController::getSelectedBrand()?->name }}</span>
@endif
</p>
</div>

View File

@@ -64,7 +64,7 @@
@if($img)
<div class="avatar"><div class="mask mask-squircle w-10 h-10"><img src="{{ asset('storage/' . $img->path) }}" /></div></div>
@else
<div class="avatar placeholder"><div class="bg-neutral text-neutral-content mask mask-squircle w-10"><span class="icon-[lucide--box] size-5"></span></div></div>
<div class="avatar placeholder"><div class="bg-base-200 text-base-content/40 mask mask-squircle w-10"><span class="icon-[lucide--box] size-5"></span></div></div>
@endif
</td>
<td><span class="font-mono text-xs">{{ $product->sku }}</span></td>
@@ -86,13 +86,13 @@
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->id]) }}">
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->hashid]) }}">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.products.destroy', [$business->slug, $product->id]) }}" method="POST" onsubmit="return confirm('Delete this product?')" class="w-full">
<form action="{{ route('seller.business.products.destroy', [$business->slug, $product->hashid]) }}" method="POST" onsubmit="return confirm('Delete this product?')" class="w-full">
@csrf
@method('DELETE')
<button type="submit" class="text-error w-full text-left">

View File

@@ -35,6 +35,24 @@ Route::bind('invoice', function (string $value) {
->firstOrFail();
});
// Custom route model binding for products (business-scoped via brand)
// This ensures sellers can only access products from their own business
Route::bind('product', function (string $value) {
// Get the business from the route (will be resolved by business binding first)
$business = request()->route('business');
if (!$business) {
abort(404, 'Business not found');
}
// Find product by hashid (5 char hash) that belongs to this business via brand
$product = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->where('hashid', $value)->firstOrFail();
return $product;
});
// Seller-specific routes under /s/ prefix (moved from /b/)
Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Root redirect to dashboard
@@ -223,19 +241,48 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
});
// Brand Management (business-scoped)
Route::prefix('brands')->name('brands.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\BrandController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\BrandController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\BrandController::class, 'store'])->name('store');
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
Route::put('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'update'])->name('update');
Route::delete('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'destroy'])->name('destroy');
// Brand Preview - allows sellers to preview their brand menu as buyers see it
Route::get('/{brand}/browse/preview', [\App\Http\Controllers\Seller\BrandPreviewController::class, 'preview'])->name('preview');
});
// Settings Management (business-scoped)
Route::prefix('settings')->name('settings.')->group(function () {
Route::get('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'companyInformation'])->name('company-information');
Route::put('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'updateCompanyInformation'])->name('company-information.update');
Route::get('/users', [\App\Http\Controllers\Seller\SettingsController::class, 'users'])->name('users');
Route::post('/users/invite', [\App\Http\Controllers\Seller\SettingsController::class, 'inviteUser'])->name('users.invite');
Route::patch('/users/{user}', [\App\Http\Controllers\Seller\SettingsController::class, 'updateUser'])->name('users.update');
Route::delete('/users/{user}', [\App\Http\Controllers\Seller\SettingsController::class, 'removeUser'])->name('users.remove');
Route::get('/orders', [\App\Http\Controllers\Seller\SettingsController::class, 'orders'])->name('orders');
Route::put('/orders', [\App\Http\Controllers\Seller\SettingsController::class, 'updateOrders'])->name('orders.update');
Route::get('/brands', [\App\Http\Controllers\Seller\SettingsController::class, 'brands'])->name('brands');
Route::get('/payments', [\App\Http\Controllers\Seller\SettingsController::class, 'payments'])->name('payments');
Route::get('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'invoices'])->name('invoices');
Route::put('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'updateInvoices'])->name('invoices.update');
Route::get('/manage-licenses', [\App\Http\Controllers\Seller\SettingsController::class, 'manageLicenses'])->name('manage-licenses');
Route::get('/plans-and-billing', [\App\Http\Controllers\Seller\SettingsController::class, 'plansAndBilling'])->name('plans-and-billing');
Route::get('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'notifications'])->name('notifications');
Route::put('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'updateNotifications'])->name('notifications.update');
Route::get('/reports', [\App\Http\Controllers\Seller\SettingsController::class, 'reports'])->name('reports');
// Category Management (under settings)
Route::prefix('categories')->name('categories.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\CategoryController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\CategoryController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\CategoryController::class, 'store'])->name('store');
Route::get('/{category}/edit', [\App\Http\Controllers\Seller\CategoryController::class, 'edit'])->name('edit');
Route::put('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'update'])->name('update');
Route::delete('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'destroy'])->name('destroy');
});
});
});
});