Compare commits
2 Commits
develop
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54788efd9e | ||
|
|
825f4fcf30 |
@@ -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": []
|
||||
|
||||
19
.env.example
19
.env.example
@@ -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}"
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
417
docs/FILE_STORAGE.md
Normal 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);
|
||||
```
|
||||
361
docs/MINIO_SECURITY_SETUP.md
Normal file
361
docs/MINIO_SECURITY_SETUP.md
Normal 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!
|
||||
```
|
||||
@@ -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">></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">></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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user