پروژه را روی هاست اشتراکی جدید آپلود کردیم. فایل‌ها همان فایل‌های لوکال بودند، دیتابیس ایمپورت شده بود و .env هم با مقدارهای درست پر شده بود. دامنه را باز کردیم و به‌جای صفحه‌ی اول سایت، فهرست پوشه‌ها را دیدیم: app، bootstrap، config، vendor و پایین‌تر public. این اولین برخورد ما با مسئله‌ی همیشگیِ لاراول در public_html بود: ریشه‌ی دامنه روی سی‌پنل پوشه‌ی public_html است، ولی ریشه‌ی وبِ لاراول باید پوشه‌ی public باشد.

اولین واکنش این بود که خب، پس آدرس درست example.com/public است. زدیم و سایت واقعاً بالا آمد. یک لحظه خوشحال شدیم و بلافاصله فهمیدیم چه اتفاق بدی افتاده. اگر public از بیرون دیده می‌شود، یعنی خود پروژه هم دیده می‌شود. آدرس example.com/.env را در مرورگر زدیم و مرورگر فایل را دانلود کرد. رمز دیتابیس، APP_KEY، کلید درگاه پرداخت، همه در یک فایل متنی ساده.

این یادداشت همان مسیر است: چرا این تناقض اصلاً وجود دارد، دو راه‌حل رایجی که مردم انتخاب می‌کنند و هر دو بد هستند، راه درست و جایگزینش وقتی هاست اجازه نمی‌دهد، فایل .htaccess استاندارد لاراول خط به خط، و آن دسته ۴۰۴هایی که هیچ ربطی به کد شما ندارند.

چرا لاراول با ریشه‌ی public_html جور در نمی‌آید#

لاراول یک «کنترلر جلویی» دارد: فایل public/index.php. هر درخواستی که به سایت می‌رسد باید به همین یک فایل برسد و بقیه‌ی کارها داخل PHP انجام شود. برای همین طراحی، لاراول پوشه‌هایش را به دو دسته تقسیم کرده است.

آن‌چه باید از وب دیده شود، فقط داخل public است: index.php، فایل‌های ساخته‌شده‌ی CSS و JS، تصویرها، favicon و robots.txt.

آن‌چه هرگز نباید از وب قابل دسترس باشد، بقیه‌ی پروژه است:

  • فایل .env که رمز دیتابیس، APP_KEY و کلید همه‌ی سرویس‌های بیرونی داخلش است.
  • پوشه‌ی storage که لاگ‌ها، کش، فایل‌های سشن و فایل‌های آپلودی خصوصی را نگه می‌دارد. یک storage/logs/laravel.log معمولی می‌تواند مسیر کامل فایل‌ها، بخشی از کوئری‌ها و حتی مقدار متغیرها را لو بدهد.
  • پوشه‌ی vendor با کل کد پکیج‌ها؛ نسخه‌ی دقیق هر پکیج از روی همین پوشه خوانده می‌شود و کار مهاجم را برای پیدا کردن آسیب‌پذیریِ شناخته‌شده ساده می‌کند.
  • پوشه‌ی database با مهاجرت‌ها و سیدرها، که ساختار جدول‌ها و گاهی داده‌ی نمونه را نشان می‌دهد.
  • پوشه‌ی .git اگر پروژه را با گیت منتقل کرده باشید. کل تاریخچه‌ی کد از همین‌جا قابل بازسازی است.

حالا طرف مقابل: روی هاست اشتراکی با سی‌پنل، دامنه‌ی اصلی به‌طور پیش‌فرض به پوشه‌ی ~/public_html وصل است. هر چیزی که داخل آن بگذارید از وب قابل دسترس است، مگر این‌که صریحاً جلویش را بگیرید. همین‌جا تناقض شکل می‌گیرد: هاست می‌گوید ریشه public_html است، لاراول می‌گوید ریشه باید public باشد.

کل بحث این مقاله یک جمله است: به‌جای این‌که لاراول را به شکل هاست دربیاورید، ریشه‌ی هاست را به پوشه‌ی درست اشاره بدهید.

مقایسه‌ی چیدمان خطرناک و چیدمان درست پوشه‌های لاراول روی هاست اشتراکی

راه بد اول: ریختن کل پروژه در public_html#

رایج‌ترین کاری که انجام می‌شود این است: فایل زیپ پروژه را داخل public_html باز می‌کنند و بعد یا آدرس را با /public می‌دهند، یا یک index.php سرِ هم می‌کنند که درخواست را به public/index.php بفرستد.

سایت کار می‌کند. مشکل جای دیگری است. یک دستور ساده کافی است تا ببینید چه چیزی بیرون افتاده:

Bash
curl -s https://example.com/.env | head -n 8

اگر خروجی چیزی شبیه این باشد، پرونده همان‌جا بسته است:

INI
APP_NAME=Debug
APP_ENV=production
APP_KEY=base64:0m3Zx8Qk1sT7vB2nR4pW9yL6cD5hJ0aE8fG3uI1oK2s=
APP_DEBUG=false
DB_DATABASE=user_shop
DB_USERNAME=user_shopadmin
DB_PASSWORD=***

و این تنها فایل نیست. این‌ها هم معمولاً باز هستند:

Bash
curl -sI https://example.com/storage/logs/laravel.log | head -n 1
curl -sI https://example.com/.git/config | head -n 1
curl -sI https://example.com/composer.lock | head -n 1

با APP_KEY می‌شود کوکی سشن را جعل کرد. با رمز دیتابیس، اگر هاست اجازه‌ی اتصال از بیرون بدهد، مستقیم می‌شود وارد دیتابیس شد. با composer.lock نسخه‌ی دقیق هر پکیج معلوم است. و لاگ لاراول، مخصوصاً وقتی حالت اشکال‌زدایی روشن مانده باشد، مسیر فایل‌ها و بخشی از داده را نشان می‌دهد؛ دلیل این‌که چرا APP_DEBUG روی هاست اشتراکی باید خاموش باشد هم دقیقاً همین است.

جمله‌ای که همیشه در دفاع از این روش می‌شنویم این است که «کسی که آدرس فایل را نمی‌داند». اسکنرهای خودکار همین چند مسیر ثابت را روی همه‌ی دامنه‌ها امتحان می‌کنند. اگر لاگ دسترسی هاستتان را باز کنید، درخواست به /.env را احتمالاً از همین هفته‌ی اول پیدا می‌کنید.

راه بد دوم: دست‌کاری index.php و ماندن اپلیکیشن زیر ریشه#

راه دوم کمی هوشمندانه‌تر به نظر می‌رسد و برای همین خطرناک‌تر است. محتوای پوشه‌ی public را داخل public_html می‌ریزند، بقیه‌ی پروژه را در public_html/laravel می‌گذارند و بعد مسیرهای index.php را عوض می‌کنند:

PHP
require __DIR__.'/laravel/vendor/autoload.php';

$app = require_once __DIR__.'/laravel/bootstrap/app.php';

ظاهر کار تمیز است: آدرس سایت درست شده و /public از URL حذف شده. ولی پوشه‌ی اپلیکیشن هنوز زیر ریشه‌ی وب است. یعنی این آدرس هنوز جواب می‌دهد:

Bash
curl -s https://example.com/laravel/.env | head -n 3

از این‌جا به بعد معمولاً وارد یک مسابقه‌ی بی‌پایان می‌شوند: با .htaccess جلوی پوشه‌ی laravel را می‌بندند، بعد یادشان می‌رود روی زیرشاخه‌ها هم ببندند، بعد یک قانون بازنویسیِ دیگر ترتیب را به هم می‌زند. یک تنظیم اشتباه در یک فایل، همه‌ی محافظت را برمی‌دارد و هیچ خطایی هم نمی‌دهد؛ فقط بی‌صدا باز می‌شود.

سه ایراد دیگر هم دارد که در عمل بیشتر آزار می‌دهد:

  • فایل public/index.php جزو فایل‌های خود فریمورک است. با هر ارتقای لاراول یا هر git pull دوباره جایگزین می‌شود و باید هر بار دستی ویرایشش کنید. یک بار فراموش کنید، سایت با خطای «کلاس پیدا نشد» می‌خوابد.
  • دستور php artisan از ریشه‌ی پروژه اجرا می‌شود، نه از ریشه‌ی وب. با این چیدمان دو مفهوم «ریشه» با هم قاطی می‌شوند و دستورهای کش و مهاجرت از مسیر اشتباه اجرا می‌شوند.
  • تابع public_path() همچنان پوشه‌ی public داخل پروژه را برمی‌گرداند، نه public_html را. یعنی هر پکیجی که فایلی در ریشه‌ی وب می‌نویسد، آن را جایی می‌نویسد که وب‌سرور نمی‌بیند. storage:link هم دقیقاً به همین دلیل لینک را در جای بی‌مصرف می‌سازد.

خلاصه: این روش امنیت را به یک فایل تنظیمات شکننده گره می‌زند و نگهداری را برای همیشه سنگین می‌کند.

راه درست: اپلیکیشن بیرون از public_html و ریشه روی پوشه‌ی public#

راه درست ساده است. کل پروژه بیرون از ریشه‌ی وب می‌ماند و فقط ریشه‌ی دامنه را به پوشه‌ی public همان پروژه اشاره می‌دهید.

  1. پروژه را در پوشه‌ای بیرون از public_html بگذارید، مثلاً ~/app:
Bash
cd ~
mkdir -p app
# فایل‌های پروژه را داخل ~/app بگذارید
ls ~/app/public/index.php
  1. در سی‌پنل به بخش Domains بروید. کنار دامنه گزینه‌ی مدیریت یا ویرایش را باز کنید و مقدار Document Root را از public_html به app/public تغییر بدهید. مسیر نسبت به پوشه‌ی خانگی حساب می‌شود، پس همان app/public کافی است.

بسته به نسخه‌ی سی‌پنل، این گزینه ممکن است زیر Domains، Addon Domains یا Subdomains باشد. روی بعضی هاست‌ها ریشه‌ی دامنه‌ی اصلی قفل است و فقط دامنه‌های اضافه و زیردامنه‌ها قابل تغییرند؛ در آن حالت سراغ بخش بعدی بروید یا از پشتیبانی بخواهید ریشه را جابه‌جا کند. این کار برای پشتیبانی یک تغییر یک‌خطی است.

  1. مجوزها را چک کنید. پوشه‌ها 755 و فایل‌ها 644 کافی است. پوشه‌های storage و bootstrap/cache باید برای کاربر PHP قابل نوشتن باشند:
Bash
find ~/app -type d -exec chmod 755 {} \;
find ~/app -type f -exec chmod 644 {} \;
chmod -R 775 ~/app/storage ~/app/bootstrap/cache

روی هاست اشتراکی معمولاً PHP با همان کاربر سی‌پنل اجرا می‌شود، پس 755 هم برای storage کار می‌کند و نیازی به 777 نیست. 777 را روی هیچ پوشه‌ای نگذارید.

  1. لینک پوشه‌ی فایل‌های عمومی را بسازید. جزئیات این کار روی سی‌پنل و تله‌های مسیر مطلق را در یادداشت storage:link روی سی‌پنل نوشته‌ایم:
Bash
cd ~/app && php artisan storage:link
  1. بعد از جابه‌جایی ریشه، پوشه‌ی public_html قدیمی دیگر بی‌مصرف است. خالی‌اش کنید یا اسمش را عوض کنید تا اشتباهی چیزی از آن سرو نشود. اگر گواهی SSL خودکار هاست برای اعتبارسنجی به public_html نیاز دارد، پوشه را نگه دارید ولی خالی بماند.

اگر تازه در حال بالا آوردن پروژه روی هاست اشتراکی هستید، بقیه‌ی کارهای لازم را در چک‌لیست هاست اشتراکی برای لاراول جمع کرده‌ایم.

وقتی تغییر ریشه‌ی دامنه ممکن نیست: راه سیمبولیک‌لینک#

بعضی هاست‌ها اجازه‌ی تغییر Document Root دامنه‌ی اصلی را نمی‌دهند. در این حالت می‌شود public_html را با یک سیمبولیک‌لینک به ~/app/public وصل کرد:

Bash
mv ~/public_html ~/public_html.bak
ln -s ~/app/public ~/public_html
ls -l ~ | grep public_html

خروجی باید نشان بدهد که public_html یک لینک است و به app/public اشاره می‌کند.

سه هشدار که باید قبل از این کار بدانید:

  • آپاچی باید اجازه‌ی دنبال کردن لینک را داشته باشد. روی بیشتر هاست‌های سی‌پنل Options +SymLinksIfOwnerMatch فعال است و چون هر دو مسیر مالک یکسان دارند مشکلی پیش نمی‌آید. اگر پیکربندی سرور سخت‌گیرتر باشد، نتیجه خطای ۴۰۳ است.
  • بعضی ابزارهای سی‌پنل، مثل نصب‌کننده‌های خودکار یا ابزار گواهی SSL، ممکن است public_html را دوباره به شکل پوشه بسازند و لینک را از بین ببرند. بعد از هر کار مدیریتی در پنل، یک بار وضعیت لینک را چک کنید.
  • پشتیبان‌گیری خودکار هاست ممکن است لینک را دنبال نکند یا برعکس، فایل‌ها را دو بار بردارد. یک بار خروجی بک‌آپ را نگاه کنید.

اگر نه تغییر ریشه ممکن بود و نه سیمبولیک‌لینک، آخرین گزینه این است: محتوای public را در public_html بگذارید ولی بقیه‌ی پروژه را بیرون از ریشه نگه دارید و مسیرهای index.php را به بیرون اشاره بدهید. این با راه بد دوم یک تفاوت اساسی دارد: در آن روش پوشه‌ی اپلیکیشن زیر ریشه می‌ماند، این‌جا نمی‌ماند. باز هم بدهی‌اش را می‌پردازید — با هر ارتقا باید index.php را دوباره ویرایش کنید — ولی دست‌کم .env از وب دیده نمی‌شود.

فایل htaccess استاندارد لاراول، خط به خط#

فایل public/.htaccess که با خود لاراول می‌آید تقریباً همیشه همین است:

Apache
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On

    RewriteCond %{HTTP:Authorization} .
    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} (.+)/$
    RewriteRule ^ %1 [L,R=301]

    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [L]
</IfModule>

حالا خط به خط:

<IfModule mod_rewrite.c> — همه‌ی محتوای فایل داخل این شرط است. اگر ماژول بازنویسی روی سرور فعال نباشد، آپاچی به‌جای خطا دادن، کل بلوک را نادیده می‌گیرد. این «مهربانی» دقیقاً منبع بخش بزرگی از ۴۰۴های عجیب است، چون هیچ نشانه‌ای باقی نمی‌گذارد.

Options -MultiViews -Indexes — دو کار جدا. -MultiViews مذاکره‌ی محتوا را خاموش می‌کند؛ وگرنه درخواست /report ممکن است خودش به report.php یا report.html وصل شود و روت لاراول هیچ‌وقت اجرا نشود. -Indexes جلوی نمایش فهرست فایل‌های یک پوشه را می‌گیرد.

RewriteEngine On — موتور بازنویسی را روشن می‌کند. بدون این خط بقیه‌ی قانون‌ها اجرا نمی‌شوند.

قانون Authorization — بعضی پیکربندی‌ها هدر Authorization را به PHP نمی‌رسانند. این دو خط می‌گویند اگر هدر Authorization مقداری دارد، همان مقدار را در متغیر محیطی HTTP_AUTHORIZATION بگذار. اگر ورود با توکن یا Bearer دارید و همه‌جا کاربر را «احراز نشده» می‌بینید، نبودن همین دو خط اولین چیزی است که باید چک کنید.

قانون اسلش پایانی — این سه خط اسلش انتهایی را حذف می‌کنند، نه اضافه. شرط اول می‌گوید فقط وقتی که مسیر درخواستی یک پوشه‌ی واقعی نیست. شرط دوم آدرس‌هایی را می‌گیرد که به / ختم می‌شوند و بخش قبل از آن را در %1 نگه می‌دارد. قانون هم با R=301 به همان آدرس بدون اسلش هدایت دائمی می‌دهد. نتیجه این است که /blog/post/ و /blog/post دو آدرس جدا برای گوگل نباشند.

قانون نهایی — قلب ماجرا. اگر مسیر درخواستی یک پوشه‌ی واقعی نبود (!-d) و یک فایل واقعی هم نبود (!-f)، درخواست را به index.php بفرست. پرچم [L] یعنی همین‌جا تمام. توجه کنید که مسیر اصلی در URL دست‌نخورده باقی می‌ماند؛ لاراول آن را از REQUEST_URI می‌خواند، نه از پارامتر بازنویسی.

ترتیب همین دو شرط منفی، مهم‌ترین نکته‌ی این فایل است: فایل و پوشه‌ی واقعی همیشه بر روت لاراول اولویت دارند. این دقیقاً سرچشمه‌ی یکی از ۴۰۴هایی است که در بخش بعد می‌آید.

۴۰۴های عجیب و ریشه‌هایشان#

قبل از هر چیز یک تفکیک که نصف کار را جلو می‌برد: ۴۰۴ را چه کسی ساخته است؟

  • اگر صفحه‌ی خطا ظاهر ساده‌ی آپاچی دارد و متنی شبیه «Not Found — The requested URL was not found on this server» نشان می‌دهد، درخواست اصلاً به PHP نرسیده است. مقصر در لایه‌ی وب‌سرور است.
  • اگر صفحه‌ی ۴۰۴ خود لاراول است (یا قالب errors/404.blade.php شماست)، درخواست رسیده و لاراول روتی برایش پیدا نکرده. مقصر در لایه‌ی برنامه است.
مسیر یک درخواست از آپاچی تا روتر لاراول و نقطه‌هایی که ۴۰۴ می‌سازند

پنج ریشه‌ی رایج برای ۴۰۴ سمت وب‌سرور:

۱. نبودن AllowOverride. اگر در پیکربندی آپاچی برای این مسیر AllowOverride None باشد، فایل .htaccess اصلاً خوانده نمی‌شود. علامت مشخصش این است که صفحه‌ی اول سایت درست بالا می‌آید (چون DirectoryIndex خودش index.php را برمی‌دارد) ولی هر آدرس دیگری ۴۰۴ می‌دهد. تست قطعی‌اش هم ساده است: یک خط بی‌معنی به اول .htaccess اضافه کنید:

Apache
ThisIsNotADirective

حالا سایت را باز کنید. اگر خطای ۵۰۰ گرفتید، یعنی فایل خوانده می‌شود و AllowOverride مشکلی ندارد. اگر سایت بدون تغییر کار کرد، یعنی فایل اصلاً خوانده نمی‌شود. خط را بعد از تست حتماً پاک کنید.

۲. خاموش بودن mod_rewrite. همان‌طور که گفتیم، <IfModule> این حالت را بی‌صدا رد می‌کند. علامتش با حالت قبل یکی است. برای تشخیص، شرط <IfModule mod_rewrite.c> و خط پایانی </IfModule> را موقتاً بردارید. اگر ماژول واقعاً نباشد، آپاچی روی RewriteEngine On خطای ۵۰۰ می‌دهد و همان خطا جواب شماست. روی هاست اشتراکی این ماژول تقریباً همیشه فعال است؛ اگر نبود، کاری از دست شما برنمی‌آید جز تیکت زدن.

۳. فایل .htaccess که اصلاً آپلود نشده. چون اسمش با نقطه شروع می‌شود، فایل مخفی است. کلاینت FTP به‌طور پیش‌فرض فایل‌های مخفی را نشان نمی‌دهد و آپلود هم نمی‌کند. در فایل‌منیجر سی‌پنل هم باید از تنظیمات، گزینه‌ی نمایش فایل‌های مخفی را روشن کنید تا دیده شود. چک کردنش یک دستور است:

Bash
ls -la ~/app/public/ | grep htaccess

اگر خالی برگشت، فایل نیست. همین.

۴. حساسیت به بزرگی و کوچکی حروف. لوکال شما احتمالاً ویندوز یا مک است و در هر دو، Logo.png و logo.png یک فایل‌اند. لینوکس این‌طور نیست. فایلی که در قالب با نام logo.png صدا زده‌اید ولی روی دیسک Logo.png است، روی هاست ۴۰۴ می‌دهد. همین قاعده برای نام فایل ویوها هم صادق است: resources/views/Home.blade.php با view('home') روی لوکال کار می‌کند و روی هاست خطای «view not found» می‌دهد. برای پیدا کردن موارد مشکوک:

Bash
find ~/app/public ~/app/resources -name '*[A-Z]*' -type f

۵. تصادم مسیر با یک فایل یا پوشه‌ی واقعی. یادتان هست که قانون آخر گفت «اگر فایل یا پوشه‌ی واقعی نبود». اگر روتی به نام /build تعریف کرده‌اید و در public هم پوشه‌ی build وجود دارد (خروجی ساخت دارایی‌ها)، آپاچی به لاراول کاری ندارد و خودش پوشه را سرو می‌کند. چون -Indexes فعال است و پوشه فایل index ندارد، نتیجه ۴۰۳ آپاچی است، نه اجرای روت شما. همین اتفاق برای /storage می‌افتد وقتی لینک public/storage شکسته باشد یا اصلاً ساخته نشده باشد. نام روت‌ها را با محتوای پوشه‌ی public مقایسه کنید:

Bash
ls ~/app/public
php artisan route:list --columns=uri

محافظت اضافه: فایل‌های مخفی و فهرست پوشه‌ها#

حتی وقتی چیدمان درست است و فقط public زیر ریشه قرار دارد، چند خط اضافه در .htaccess ارزش دارد. مثلاً فایل .htaccess خودش، یا فایل‌های مخفی‌ای که ابزارهای دیگر جا می‌گذارند.

Apache
Options -Indexes

<FilesMatch "^\.">
    Require all denied
</FilesMatch>

Options -Indexes جلوی نمایش فهرست فایل‌ها را در پوشه‌های بدون index می‌گیرد. الگوی ^\. هر فایلی را که نامش با نقطه شروع شود می‌بندد؛ یعنی .env و .htaccess و مشابه‌هایشان.

ولی حواستان باشد FilesMatch فقط با نام فایل کار دارد، نه با مسیر. یعنی این بلوک جلوی /.git/config را نمی‌گیرد، چون نام آن فایل config است و با نقطه شروع نمی‌شود. برای پوشه‌های نقطه‌دار به یک قانون مسیرمحور نیاز دارید:

Apache
RedirectMatch 404 /\.(git|svn|env)

نکته‌ی نسخه: Require all denied نحو آپاچی ۲.۴ است. روی سرورهای قدیمی‌تر با آپاچی ۲.۲ باید Deny from all بنویسید. بیشتر هاست‌های امروز ۲.۴ هستند، ولی اگر بعد از افزودن این بلوک خطای ۵۰۰ گرفتید، اولین چیزی که باید عوض کنید همین است.

اگر خواستید پسوندهای خاصی را هم ببندید، حواستان به عوارض جانبی باشد:

Apache
<FilesMatch "\.(env|log|sql|sh|bak|lock|yml|yaml)$">
    Require all denied
</FilesMatch>

اضافه کردن json به این فهرست وسوسه‌برانگیز است ولی خطرناک: اگر سایت شما site.webmanifest، manifest.json یا هر فایل JSON دیگری را از سمت مرورگر می‌خواند، آن هم بسته می‌شود و بی‌سروصدا خراب می‌شود.

چرا htaccess نباید تنها ستون امنیت باشد#

فایل .htaccess یک لایه‌ی راحت است، نه یک مرز. سه دلیل روشن:

  • فقط وقتی کار می‌کند که وب‌سرور آپاچی باشد و AllowOverride اجازه بدهد. اگر روزی سایت پشت LiteSpeed با پیکربندی متفاوت یا پشت Nginx برود، این فایل ممکن است بخشی از قانون‌هایش را از دست بدهد. LiteSpeed بیشتر دستورهای آپاچی را می‌فهمد، ولی «بیشتر» با «همه» فرق دارد.
  • اگر فایل حذف شود، جابه‌جا شود، یا یک قانون بازنویسی جلوتر مسیر را عوض کند، محافظت بی‌صدا از بین می‌رود. هیچ خطایی نمی‌بینید؛ فقط یک روز فایلی که نباید، باز است.
  • محافظت با .htaccess جنس «پنهان‌کردن» دارد نه «نبودن». تنها چیزی که به یک فایل تنظیمات وابسته نیست، این است که فایل حساس اصلاً زیر ریشه‌ی وب نباشد.

پس این قانون‌ها را به‌عنوان کمربند دوم بنویسید، نه به‌عنوان راه‌حل. راه‌حل، همان چیدمانی است که در بخش «راه درست» گفتیم.

کش و فشرده‌سازی در همان فایل#

وقتی .htaccess را دست گرفته‌اید، دو بلوک دیگر هم ارزش دارد. اول فشرده‌سازی:

Apache
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
    AddOutputFilterByType DEFLATE application/javascript application/json
    AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>

فهرست عمداً فقط شامل انواع متنی است. تصویرهای jpg و png و webp از قبل فشرده‌اند و گذاشتنشان در این فهرست فقط CPU مصرف می‌کند بدون اینکه حجم کم شود. image/svg+xml استثناست چون در واقع متن است.

بعد کش مرورگر:

Apache
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 month"
    ExpiresByType image/webp "access plus 1 month"
    ExpiresDefault "access plus 1 hour"
</IfModule>

و اگر دارایی‌هایتان نام هش‌دار دارند، هدر immutable هم منطقی است:

Apache
<IfModule mod_headers.c>
    <FilesMatch "\.(css|js|woff2)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
</IfModule>

یک هشدار جدی این‌جا لازم است: این هدر را فقط روی فایل‌هایی بگذارید که با هر تغییر، نامشان عوض می‌شود — مثل خروجی Vite که هش را داخل نام فایل می‌گذارد. اگر همین قانون روی فایلی با نام ثابت مثل app.css بنشیند، مرورگر کاربر تا یک سال نسخه‌ی قدیمی را نگه می‌دارد و هیچ Ctrl+F5 معمولی هم نجاتش نمی‌دهد. این دقیقاً همان تله‌ای است که در یادداشت کش immutable و نرسیدن CSS شرحش را نوشته‌ایم.

تست بعد از استقرار#

بعد از هر جابه‌جایی، این چند دستور را بزنید. کمتر از یک دقیقه وقت می‌گیرد و جلوی بدترین اتفاق‌ها را می‌گیرد.

اول، مطمئن شوید فایل‌های حساس از وب در دسترس نیستند. جواب درست 403 یا 404 است:

Bash
for p in /.env /storage/logs/laravel.log /composer.json /.git/config /vendor/autoload.php; do
  echo -n "$p -> "
  curl -s -o /dev/null -w "%{http_code}\n" "https://example.com$p"
done

اگر هر کدام 200 برگرداند، همین‌جا کار را متوقف کنید و چیدمان را درست کنید. بعدش هم APP_KEY را عوض کنید و رمز دیتابیس و کلیدهای سرویس‌ها را از نو بسازید؛ چیزی که یک بار بیرون رفته، رفته است.

دوم، مطمئن شوید روت‌ها واقعاً از .htaccess رد می‌شوند. صفحه‌ی اول و یک آدرس داخلی را جدا تست کنید:

Bash
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/login

اگر اولی 200 بدهد و دومی 404، مشکل از قانون‌های بازنویسی است نه از روت‌های شما. سراغ بخش ۴۰۴ برگردید.

سوم، لینک فایل‌های عمومی را چک کنید:

Bash
ls -l ~/app/public/storage
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/storage/test.txt

خروجی ls -l باید یک لینک نشان بدهد که به ../storage/app/public اشاره می‌کند. اگر پوشه‌ی معمولی بود یا اصلاً نبود، php artisan storage:link را دوباره بزنید.

چهارم، هدر کش یک دارایی هش‌دار را ببینید:

Bash
curl -sI https://example.com/build/assets/app-8f2b1c9d.css | grep -i cache-control

درس اصلی#

مسئله‌ی public_html یک مسئله‌ی امنیتی است که لباس مسئله‌ی پیکربندی پوشیده. هر راه‌حلی که کارش «پنهان کردن» فایل‌های پروژه از وب باشد، دیر یا زود شکست می‌خورد، چون به یک فایل تنظیمات و یک ماژول اختیاری وابسته است. تنها راه‌حلی که همیشه کار می‌کند این است که فایل حساس اصلاً زیر ریشه‌ی وب نباشد.

و درباره‌ی ۴۰۴ها: قبل از باز کردن routes/web.php، اول تشخیص بدهید صفحه‌ی خطا را آپاچی ساخته یا لاراول. این یک نگاه، مسیر عیب‌یابی را دو شاخه می‌کند و معمولاً چند ساعت را به چند دقیقه تبدیل می‌کند.