feat(security): implement secure file storage and shipment access control

Introduce a 'secure' filesystem disk to prevent public access to sensitive
commitment form uploads. Files are now stored in a non-public directory
and served via a protected controller method that validates user ownership.

Additionally, implement shipment authorization policies to ensure users
can only access PDF documents (AWB, invoice, labels) belonging to their
own orders.

Other changes:
- Add production environment check for Zarinpal gateway configuration
  to prevent accidental use of sandbox credentials.
- Move discount code and commitment form routes under authentication
  middleware for improved security.
- Add `ShipmentPolicy` to handle resource authorization.
This commit is contained in:
Kazem Alghasi 2026-10-01 03:34:28 +03:30
parent 8213ee7ee1
commit 8cf407553b
6 changed files with 102 additions and 13 deletions

View File

@ -106,7 +106,9 @@ class CommitmentFormController extends Controller
'file_type' => strtoupper(pathinfo($form->file_path, PATHINFO_EXTENSION)),
'direction' => $form->direction,
'upload_status' => $upload ? $upload->status : 'pending',
'uploaded_file_url' => $upload && $upload->uploaded_file_path ? asset('storage/' . $upload->uploaded_file_path) : null,
'uploaded_file_url' => $upload && $upload->uploaded_file_path
? route('customer.commitment-forms.download', ['shipment' => $shipment->id, 'form' => $form->id])
: null,
'uploaded_at' => $upload ? $upload->created_at : null,
'notes' => $upload ? $upload->notes : null,
];
@ -141,7 +143,8 @@ class CommitmentFormController extends Controller
try {
$file = $request->file('file');
$path = $file->store("commitment-forms/{$shipment->id}", 'public');
// 🛡️ ذخیره در دیسک امن (غیرعمومی) — فقط از طریق route محافظت‌شده قابل دانلود
$path = $file->store("commitment-forms/{$shipment->id}", 'secure');
$upload = ShipmentCommitmentForm::updateOrCreate(
[
@ -163,7 +166,10 @@ class CommitmentFormController extends Controller
'message' => 'فایل با موفقیت آپلود شد.',
'data' => [
'id' => $upload->id,
'file_url' => asset('storage/' . $path),
'file_url' => route('customer.commitment-forms.download', [
'shipment' => $shipment->id,
'form' => $form->id,
]),
'file_type' => $upload->uploaded_file_type,
'status' => $upload->status,
'uploaded_at' => $upload->created_at,
@ -176,5 +182,38 @@ class CommitmentFormController extends Controller
], 500);
}
}
/**
* دانلود فرم تعهدنامه امزاشده (محافظت‌شده با بررسی مالکیت).
* GET /api/v1/customer/orders/{shipment}/commitment-forms/{form}/download
*/
public function downloadSigned(Shipment $shipment, CommitmentForm $form)
{
$user = auth()->user();
if ($shipment->user_id !== $user->id) {
return response()->json([
'success' => false,
'message' => 'شما به این سفارش دسترسی ندارید.',
], 403);
}
$upload = ShipmentCommitmentForm::where('shipment_id', $shipment->id)
->where('commitment_form_id', $form->id)
->firstOrFail();
if (!$upload->uploaded_file_path) {
return response()->json([
'success' => false,
'message' => 'فایلی برای این تعهدنامه آپلود نشده است.',
], 404);
}
return Storage::disk('secure')->download(
$upload->uploaded_file_path,
'commitment-' . $form->id . '-' . $shipment->awb_no . '.' . $upload->uploaded_file_type
);
}
}
}

View File

@ -15,8 +15,10 @@ class ShipmentPdfController extends Controller
/**
* تولید AWB PDF (برای همه نوع محموله‌ها)
*/
public function awb(Shipment $shipment)
public function awb(Shipment $shipment)
{
$this->authorize('view', $shipment);
try {
Log::info('Generating AWB PDF', ['shipment_id' => $shipment->id]);
@ -47,6 +49,7 @@ class ShipmentPdfController extends Controller
*/
public function invoice(Shipment $shipment)
{
$this->authorize('view', $shipment);
try {
// بارگذاری items برای بررسی
$shipment->loadMissing(['items']);
@ -109,6 +112,7 @@ class ShipmentPdfController extends Controller
*/
public function label(Shipment $shipment)
{
$this->authorize('view', $shipment);
try {
$content = $this->pdf->label($shipment);
@ -134,6 +138,7 @@ class ShipmentPdfController extends Controller
*/
public function importInvoice(Shipment $shipment, Request $request)
{
$this->authorize('view', $shipment);
try {
// دریافت پارامترها از درخواست
$options = $request->only([

View File

@ -0,0 +1,23 @@
<?php
namespace App\Policies;
use App\Models\Shipment;
use App\Models\User;
/**
* سیاست دسترسی به سفارش‌ها (و اسنادشان: AWB/Invoice/Label/PDF).
* - super_admin/admin/staff: هر سفارشی.
* - customer: فقط سفارش‌های خودش.
*/
class ShipmentPolicy
{
public function view(User $user, Shipment $shipment): bool
{
if ($user->hasAnyRole(['super_admin', 'admin', 'staff'])) {
return true;
}
return $shipment->user_id === $user->id;
}
}

View File

@ -93,6 +93,16 @@ class OrderPaymentService
public function initiateGatewayPayment(Shipment $shipment, Wallet $wallet): array
{
try {
// 🛡️ محافظ Production: اگه در محیط تولید، درگاه Mock فعال باشد، رد کن
if (app()->environment('production')
&& (config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing'
|| config('ifnex.zarinpal.sandbox', true))) {
throw new \RuntimeException(
'درگاه پرداخت در محیط تولید به‌درستی تنظیم نشده. '
. 'ZARINPAL_MERCHANT_ID واقعی یا ZARINPAL_SANDBOX=false در .env تنظیم کنید.'
);
}
// ۱. ایجاد تراکنش pending
$transaction = WalletTransaction::create([
'wallet_id' => $wallet->id,

View File

@ -38,7 +38,7 @@ return [
'report' => false,
],
'public' => [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
@ -47,6 +47,15 @@ return [
'report' => false,
],
// 🛡️ دیسک امن برای فایل‌های حساس (تعهدنامه‌های امزاشده).
// بدون url/serve → قابل دسترسی عمومی نیست؛ فقط از طریق route محافظت‌شده.
'secure' => [
'driver' => 'local',
'root' => storage_path('app/secure'),
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),

View File

@ -64,6 +64,14 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']);
Route::get('/wallet/{wallet}/activity-log', [WalletController::class, 'activityLog']);
// ─── Discount Codes (نیازمند احراز هویت) ───
Route::get('/discount-codes', [DiscountCodeController::class, 'index']);
Route::post('/discount-codes/validate', [DiscountCodeController::class, 'validate']);
// ─── Commitment Forms (نیازمند احراز هویت) ───
Route::get('/commitment-forms', [CommitmentFormController::class, 'index']);
Route::get('/commitment-forms/{direction}', [CommitmentFormController::class, 'byDirection']);
// ─── Customer Orders API (جدید) ───
Route::prefix('customer')->group(function () {
// پروفایل و آمار
@ -88,7 +96,8 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// تعهدنامه‌ها
Route::get('/orders/{shipment}/commitment-forms', [CommitmentFormController::class, 'shipmentForms']);
Route::post('/orders/{shipment}/commitment-forms/{form}/upload', [CommitmentFormController::class, 'uploadSigned']);
Route::get('/orders/{shipment}/commitment-forms/{form}/download', [CommitmentFormController::class, 'downloadSigned'])
->name('customer.commitment-forms.download');
// نوتیفیکیشن‌ها
Route::get('/notifications', [CustomerOrderController::class, 'notifications']);
Route::post('/notifications/{notification}/read', [CustomerOrderController::class, 'markNotificationRead']);
@ -150,15 +159,9 @@ Route::any('/v1/payment/callback', [PaymentController::class, 'callback'])
->name('payment.callback');
// ══════════════════════════════════════════════════════════════
// APIهای عمومی (بدون auth)
// APIهای عمومی (بدون auth) — فقط استعلام قیمت همگانی است
// ══════════════════════════════════════════════════════════════
Route::post('/v1/calculate', [PricingController::class, 'calculate']);
Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']);
Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']);
// API فایل‌های تعهدنامه (عمومی)
Route::get('/v1/commitment-forms', [CommitmentFormController::class, 'index']);
Route::get('/v1/commitment-forms/{direction}', [CommitmentFormController::class, 'byDirection']);
// API تأیید موبایل (عمومی)
Route::post('/v1/verify/send-code', [MobileVerificationController::class, 'sendCode']);