رفع الملفات في PHP: أفضل ممارسات الأمان

رفع الملفات في PHP: أفضل ممارسات الأمان

رفع الملفات في أي تطبيق ويب يبدو للوهلة الأولى ميزة بسيطة جدًا. زر اختيار ملف، ثم زر إرسال، ثم تُحفظ الصورة أو المستند في الخادم، وتنتهي القصة. لكن الحقيقة مختلفة تمامًا. هذه الميزة الصغيرة قد تتحول إلى واحدة من أخطر النقاط في التطبيق كله إذا لم تُبنَ بحذر.

كثير من المشاريع تبدأ هنا: “نريد أن نسمح للمستخدم برفع صورة الملف الشخصي”، أو “نحتاج إلى رفع ملف PDF”، أو “نريد من العميل أن يرسل مرفقات”. ثم بعد فترة، يظهر السؤال الذي كان يجب أن يُطرح من البداية: هل هذا الرفع آمن؟ هل يمكن لمستخدم خبيث أن يرفع ملفًا تنفيذيًا؟ هل يمكنه تخطي الفحص؟ هل يمكن أن يملأ القرص؟ هل يمكنه استغلال اسم الملف؟ هل يمكنه الوصول إلى ملفاته من خلال رابط مباشر؟ هل خزّنا الملف في المكان الصحيح أصلًا؟

هذه المقالة ليست عن “كيف ترفع ملفًا فقط”، بل عن كيف تبني نظام رفع ملفات في PHP بطريقة محترمة وآمنة، وكأنك تبني بابًا خلفيًا لا يراه أحد، لكنه يحتاج أن يكون أقوى من الباب الأمامي نفسه. وسنمشي خطوة خطوة، من الفكرة الأولى إلى التطبيق العملي، مع أمثلة PHP حقيقية، وملاحظات أمنية واضحة، ولمسة بشرية بسيطة لأن هذه المواضيع، رغم جفافها الظاهري، تمسّ كل مطوّر في لحظة ما عندما يكتشف أن ملفًا واحدًا غير مراقب يمكن أن يفتح مشكلة كبيرة.

لماذا رفع الملفات حساس جدًا؟

الملفات ليست مجرد بيانات. الملف قد يحمل:

  • كودًا خبيثًا.

  • صورة مزيفة باسم بريء.

  • ملفًا ضخمًا يستهلك الموارد.

  • اسمًا يحتوي على محاولات اختراق.

  • بيانات حساسة لا يجب أن تصل لأي مكان عام.

  • محتوى HTML أو JavaScript قد يؤدي إلى XSS إذا عُرض بشكل سيئ.

ولهذا السبب، التعامل مع الملفات يجب ألا يكون عشوائيًا. لا يكفي أن تقول: “أنا أتحقق من الامتداد” أو “أنا أسمح فقط بـ JPG وPDF”. المهاجمون يعرفون كيف يمررون الملفات من خلال الثغرات الصغيرة، وكيف يستفيدون من الثقة الزائدة في البيانات القادمة من المتصفح.

الفكرة الأساسية التي يجب أن ترافقك طوال المقال هي هذه:
أي شيء يأتي من المستخدم غير موثوق به حتى يثبت العكس.
والملف القادم من المستخدم أكثر شيء يستحق هذا الشك.

ما الذي سنبنيه في هذا المقال؟

سنغطي مجموعة واسعة من النقاط، منها:

  • بناء نموذج رفع ملفات بسيط.

  • التحقق من الأخطاء القادمة من $_FILES.

  • فحص الامتداد الصحيح.

  • فحص MIME type.

  • استخدام finfo_file() بدل الثقة العمياء.

  • تغيير اسم الملف عند الحفظ.

  • منع تنفيذ الملفات في مجلد الرفع.

  • تحديد الحجم الأقصى.

  • تنظيم التخزين.

  • رفع الصور والمستندات بشكل منفصل.

  • التعامل مع إعادة التسمية والتضارب.

  • حماية الرفع من الملفات الخبيثة.

  • إضافة CSRF token.

  • تحسين تجربة المستخدم.

  • مثال متكامل وآمن.

  • أخطاء شائعة جدًا يجب تجنبها.

كل ذلك مع كود PHP عملي يمكنك تطويره لاحقًا.

الفكرة الصحيحة قبل كتابة الكود

قبل أن تكتب move_uploaded_file()، اسأل نفسك هذه الأسئلة:

  • هل أحتاج فعلًا إلى حفظ الملف داخل مجلد يمكن الوصول إليه مباشرة من المتصفح؟

  • هل يجب أن أسمح بكل الأنواع أم فقط أنواع محددة؟

  • هل أحتاج إلى حفظ الاسم الأصلي؟

  • هل أحتاج إلى فحص المحتوى أم فقط الامتداد؟

  • هل أريد أن يكون الملف عامًا أم خاصًا؟

  • هل أحتاج إلى صور فقط أم ملفات متنوعة؟

القرار المعماري هنا مهم. أحيانًا أفضل حل أمني هو ألا تضع الملفات في مكان عام من الأساس، بل تحفظها خارج public_html أو public/، ثم تقدّمها للمستخدم من خلال سكربت PHP يمر على صلاحيات واضحة. هذا يمنع الكثير من المشاكل منذ البداية.

أساسيات رفع الملفات في PHP

عندما يرسل المستخدم نموذجًا يحتوي على ملف، فإن PHP يضع معلومات الملف داخل المصفوفة $_FILES. هذه المصفوفة تحتوي عادة على:

  • name

  • type

  • tmp_name

  • error

  • size

مثال على نموذج HTML

<form action="upload.php" method="POST" enctype="multipart/form-data">
    <label>اختر ملفًا:</label>
    <input type="file" name="user_file">
    <button type="submit">رفع الملف</button>
</form>

لماذا enctype="multipart/form-data"؟

لأن هذا النوع من الترميز ضروري لإرسال الملفات. بدون هذا السطر، لن يصل الملف أصلًا إلى الخادم بشكل صحيح.

قراءة معلومات الملف من PHP

مثال أولي

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (isset($_FILES['user_file'])) {
        echo '<pre>';
        print_r($_FILES['user_file']);
        echo '</pre>';
    }
}

سترى شيئًا مشابهًا:

Array
(
    [name] => example.jpg
    [type] => image/jpeg
    [tmp_name] => /tmp/php8f7a1
    [error] => 0
    [size] => 245678
)

لكن هنا يجب أن تبدأ الحذر.
القيمة type مثلًا لا يمكن الوثوق بها بالكامل لأنها تأتي من العميل. قد يرسل المستخدم image/jpeg حتى لو كان الملف شيئًا آخر تمامًا. لذلك سنستخدمها فقط كمؤشر، لا كدليل نهائي.

التعامل مع أخطاء الرفع

قبل أن تفحص الاسم أو الامتداد، يجب أن تتحقق من أن الرفع نفسه نجح.

مثال

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $file = $_FILES['user_file'] ?? null;

    if (!$file) {
        die('لم يتم إرسال أي ملف.');
    }

    if ($file['error'] !== UPLOAD_ERR_OK) {
        switch ($file['error']) {
            case UPLOAD_ERR_INI_SIZE:
            case UPLOAD_ERR_FORM_SIZE:
                die('الملف أكبر من الحجم المسموح.');
            case UPLOAD_ERR_PARTIAL:
                die('تم رفع الملف بشكل جزئي فقط.');
            case UPLOAD_ERR_NO_FILE:
                die('لم يتم اختيار ملف.');
            case UPLOAD_ERR_NO_TMP_DIR:
                die('مجلد مؤقت الرفع غير موجود.');
            case UPLOAD_ERR_CANT_WRITE:
                die('فشل حفظ الملف على الخادم.');
            case UPLOAD_ERR_EXTENSION:
                die('تم إيقاف الرفع بسبب إضافة في PHP.');
            default:
                die('حدث خطأ غير متوقع أثناء الرفع.');
        }
    }
}

هذا الفحص مهم جدًا. لا تبدأ أي تحقق أمني قبل التأكد أن الملف وصل سليمًا.

لماذا لا نثق باسم الملف الأصلي؟

لأن اسم الملف القادم من المستخدم قد يكون:

  • طويلًا جدًا.

  • يحتوي على رموز غريبة.

  • يحتوي على مسارات مثل ../.

  • يحتوي على أحرف قد تسبب مشاكل في أنظمة الملفات المختلفة.

  • يكرر اسمًا موجودًا بالفعل.

  • يعطي انطباعًا زائفًا عن نوع الملف.

مثال:
my-photo.jpg.php
قد يبدو صورة، لكنه في الحقيقة ملف PHP.

أو:
../config.php
وهذا واضح الخطر.

لذلك لا تعتمد على الاسم الأصلي كمفتاح للحفظ. يمكنك الاحتفاظ به في قاعدة البيانات لو أردت، لكن لا تجعله اسم الملف الفعلي على الخادم.

التحقق من الامتداد

الامتداد ليس كل شيء، لكنه بداية جيدة.
إذا كنت تسمح فقط بالصور مثل JPG وPNG وGIF، فابدأ من هناك.

مثال

<?php
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];

$originalName = $_FILES['user_file']['name'];
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

if (!in_array($extension, $allowedExtensions, true)) {
    die('نوع الملف غير مسموح.');
}

لكن تذكر: الامتداد وحده ليس كافيًا.
يمكن لأي شخص أن يعيد تسمية ملف PHP إلى image.jpg. لذلك نحتاج إلى فحص أعمق.

فحص MIME type بشكل صحيح

أفضل طريقة عملية في PHP هي استخدام finfo_file() بدل الثقة في $_FILES['type'].

مثال

<?php
$tmpPath = $_FILES['user_file']['tmp_name'];

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $tmpPath);
finfo_close($finfo);

$allowedMimeTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'application/pdf',
];

if (!in_array($mimeType, $allowedMimeTypes, true)) {
    die('نوع الملف الحقيقي غير مسموح.');
}

هذا فحص أقوى من الاعتماد على الامتداد فقط، لكنه أيضًا ليس مثاليًا لوحده. الملف قد يكون صالحًا من حيث النوع العام، لكنه ما زال يحمل محتوى غير متوقع. لذا نحتاج إلى طبقات متعددة من الدفاع.

التحقق من الصورة فعليًا

إذا كنت تقبل الصور، فالأفضل أن تتحقق منها أيضًا باستخدام getimagesize() أو أدوات مشابهة، لأن هذا الفحص يساعدك على معرفة هل الملف صورة حقيقية أم لا.

مثال

<?php
if (!getimagesize($_FILES['user_file']['tmp_name'])) {
    die('الملف ليس صورة صالحة.');
}

هذا لا يغني عن فحوصات أخرى، لكنه يضيف طبقة جيدة من الحماية.

تسمية الملفات بشكل آمن

من الأخطاء الشائعة جدًا أن تحفظ الملف بنفس اسمه الأصلي. الأفضل أن تولّد اسمًا جديدًا وعشوائيًا.

مثال باستخدام bin2hex(random_bytes())

<?php
$newFileName = bin2hex(random_bytes(16)) . '.' . $extension;

هذا يعطيك اسمًا عشوائيًا صعب التخمين، مثل:
f3a9c7d9be8a1b2c4d5e6f7a8b9c0d1e.jpg

ميزة هذا الأسلوب:

  • يمنع التضارب بين الملفات.

  • يمنع كشف اسم المستخدم الأصلي.

  • يقلل احتمالات التلاعب بالأسماء.

  • يسهل تنظيم الملفات لاحقًا.

اختيار مكان التخزين

لديك خياران رئيسيان:

1. التخزين داخل مجلد عام

مثل:
public/uploads

هذا سهل الوصول، لكنه أقل أمانًا إذا لم تحظر التنفيذ وتتحكم جيدًا في الأنواع.

2. التخزين خارج المجلد العام

مثل:
storage/uploads

ثم تعرض الملفات عبر سكربت PHP عند الحاجة.
هذا أفضل أمنيًا، خصوصًا للملفات الخاصة.

إذا كانت ملفاتك عامة مثل صور المنتجات، فقد يكون التخزين العام مقبولًا مع ضوابط قوية. أما إن كانت ملفات شخصية أو حساسة، فالأفضل أن تبقيها خارج الوصول المباشر.

منع تنفيذ الملفات داخل مجلد الرفع

إذا اضطررت إلى التخزين في مجلد عام، يجب أن تمنع تنفيذ الملفات فيه.
هذا مهم جدًا في بيئات Apache وNginx.

مثال .htaccess لمجلد الرفع في Apache

Options -Indexes
<FilesMatch "\.(php|phtml|phar|cgi|pl|py|jsp|asp|sh)$">
    Deny from all
</FilesMatch>

أو منع الوصول بطريقة أشد

php_flag engine off

لكن هذا قد لا يعمل في بعض الإعدادات، لذا يجب اختبار بيئتك.

في Nginx

تحتاج إلى ضبط إعدادات الخادم لمنع تنفيذ أي سكربت داخل مجلد الرفع.

الفكرة المهمة هنا:
لا تجعل مجلد الرفع مكانًا يمكن من خلاله تشغيل كود.

حجم الملف الأقصى

لا تترك المستخدم يرفع ما يشاء.
الملفات الضخمة قد تسبب:

  • استهلاك مساحة القرص.

  • ضغطًا على الذاكرة.

  • بطئًا في التطبيق.

  • مشاكل في الرفع المتكرر.

مثال للتحقق من الحجم

<?php
$maxSize = 2 * 1024 * 1024; // 2 MB

if ($_FILES['user_file']['size'] > $maxSize) {
    die('حجم الملف يتجاوز الحد المسموح.');
}

لكن لا تنسَ أن PHP نفسه لديه إعدادات مثل:

  • upload_max_filesize

  • post_max_size

يجب أن تكون هذه الإعدادات أكبر قليلًا من الحد الذي تفرضه في التطبيق، وإلا فلن يصل الملف أصلاً.

التحقق من ملفات فارغة أو مشبوهة

قد يصل ملف بحجم صفر أو ملف غير مكتمل. لذلك أضف فحصًا منطقيًا:

<?php
if ($_FILES['user_file']['size'] <= 0) {
    die('الملف فارغ أو غير صالح.');
}

وقد تحتاج أيضًا إلى التحقق من وجود الامتداد والمحتوى في نفس الوقت.

استخدام move_uploaded_file() بالطريقة الصحيحة

هذه الدالة هي التي تنقل الملف من المسار المؤقت إلى المسار النهائي.
ولأنها مخصصة للملفات المرفوعة عبر HTTP، فهي أفضل من rename() في هذا السياق.

مثال

<?php
$uploadDir = __DIR__ . '/uploads/';
$destination = $uploadDir . $newFileName;

if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

if (!move_uploaded_file($_FILES['user_file']['tmp_name'], $destination)) {
    die('فشل حفظ الملف.');
}

echo 'تم رفع الملف بنجاح.';

لماذا نستخدم move_uploaded_file()؟

لأنها تتحقق من أن الملف بالفعل تم رفعه عبر HTTP.
هذه طبقة أمان مهمة لا ينبغي تجاهلها.

مثال متكامل لرفع صورة بأمان

الآن دعنا نبني سكربتًا أكثر اكتمالًا لرفع صورة.

<?php
session_start();

$errors = [];
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!isset($_FILES['user_file'])) {
        $errors[] = 'لم يتم إرسال الملف.';
    } else {
        $file = $_FILES['user_file'];

        if ($file['error'] !== UPLOAD_ERR_OK) {
            $errors[] = 'حدث خطأ أثناء رفع الملف.';
        } else {
            $maxSize = 2 * 1024 * 1024; // 2MB
            if ($file['size'] > $maxSize) {
                $errors[] = 'حجم الملف كبير جدًا.';
            }

            $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif'];
            $originalName = $file['name'];
            $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

            if (!in_array($extension, $allowedExtensions, true)) {
                $errors[] = 'امتداد الملف غير مسموح.';
            }

            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mimeType = finfo_file($finfo, $file['tmp_name']);
            finfo_close($finfo);

            $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif'];
            if (!in_array($mimeType, $allowedMimeTypes, true)) {
                $errors[] = 'نوع الصورة غير مسموح.';
            }

            if (!getimagesize($file['tmp_name'])) {
                $errors[] = 'الملف ليس صورة صالحة.';
            }

            if (empty($errors)) {
                $uploadDir = __DIR__ . '/uploads/';
                if (!is_dir($uploadDir)) {
                    mkdir($uploadDir, 0755, true);
                }

                $newFileName = bin2hex(random_bytes(16)) . '.' . $extension;
                $destination = $uploadDir . $newFileName;

                if (move_uploaded_file($file['tmp_name'], $destination)) {
                    $success = 'تم رفع الصورة بنجاح.';
                } else {
                    $errors[] = 'فشل حفظ الصورة.';
                }
            }
        }
    }
}
?>

<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
    <meta charset="UTF-8">
    <title>رفع صورة</title>
</head>
<body>
    <h2>رفع صورة بشكل آمن</h2>

    <?php if (!empty($errors)): ?>
        <div>
            <?php foreach ($errors as $error): ?>
                <p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>

    <?php if ($success): ?>
        <p><?= htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?></p>
    <?php endif; ?>

    <form method="POST" enctype="multipart/form-data">
        <input type="file" name="user_file" accept="image/*">
        <button type="submit">رفع</button>
    </form>
</body>
</html>

هذا المثال جيد كبداية، لكنه ما زال قابلًا للتطوير. في التطبيقات الكبيرة، ستحتاج إلى إدارة أفضل للملفات والبيانات المرتبطة بها.

رفع ملفات PDF بأمان

إذا كنت تسمح برفع PDF، فالأمر يحتاج إلى نفس الحرص.
قد يكون الملف PDF حقيقيًا، وقد يكون باسم PDF لكنه ليس كذلك. لذلك عليك فحص:

  • الامتداد.

  • MIME type.

  • الحجم.

  • وربما حتى بعض خصائص المحتوى.

مثال

<?php
$allowedExtensions = ['pdf'];
$allowedMimeTypes = ['application/pdf'];

$extension = strtolower(pathinfo($_FILES['document']['name'], PATHINFO_EXTENSION));

if (!in_array($extension, $allowedExtensions, true)) {
    die('يسمح فقط بملفات PDF.');
}

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $_FILES['document']['tmp_name']);
finfo_close($finfo);

if (!in_array($mimeType, $allowedMimeTypes, true)) {
    die('نوع الملف غير صالح.');
}

إذا أردت حماية أعلى، يمكنك أيضًا فحص أولى بايتات الملف أو استخدام مكتبات خارجية للتحقق من PDF بطريقة أدق حسب حاجتك.

منع الثقة في $_FILES['type']

قد تظن أن هذه القيمة مفيدة:

$_FILES['user_file']['type']

لكن لا تعتمد عليها.
المستخدم يستطيع تغييرها بسهولة. هي تأتي من المتصفح أو من بيانات الطلب، وليست ضمانًا. استخدمها فقط كمعلومة إضافية، لا كأداة قرار رئيسية.

ماذا عن إعادة تسمية الملف الأصلي؟

أحيانًا نحتاج إلى عرض الاسم الأصلي للمستخدم في الواجهة، وهذا مقبول.
لكن خذ هذا الاسم وضعه في قاعدة البيانات أو في سجل خاص، لا تستخدمه مباشرة كاسم ملف على القرص.

مثال تخزين معلومات الملف

<?php
$stmt = $pdo->prepare("INSERT INTO files (original_name, stored_name, mime_type, file_size) VALUES (?, ?, ?, ?)");
$stmt->execute([
    $originalName,
    $newFileName,
    $mimeType,
    $file['size']
]);

بهذه الطريقة تحافظ على الاسم الأصلي كمرجع للمستخدم، لكنك لا تعتمد عليه تقنيًا.

جدول قاعدة بيانات مناسب للملفات

إذا كان تطبيقك يعتمد على ملفات كثيرة، فمن الأفضل أن تديرها في قاعدة البيانات.

مثال جدول

CREATE TABLE uploads (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id INT UNSIGNED NULL,
    original_name VARCHAR(255) NOT NULL,
    stored_name VARCHAR(255) NOT NULL,
    mime_type VARCHAR(100) NOT NULL,
    file_size BIGINT UNSIGNED NOT NULL,
    file_path VARCHAR(500) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

يمكنك لاحقًا ربط الملف بالمستخدم أو الطلب أو المنتج أو التذكرة، حسب نوع التطبيق.

حماية الرفع باستخدام CSRF token

حتى نموذج الرفع قد يتعرض لطلب مزيف من موقع آخر.
لذلك من الأفضل إضافة CSRF token.

توليد التوكن

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

داخل النموذج

<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">

التحقق

<?php
$token = $_POST['csrf_token'] ?? '';

if (!hash_equals($_SESSION['csrf_token'], $token)) {
    die('فشل التحقق الأمني.');
}

هذا بسيط لكنه مهم جدًا في النماذج الحساسة.

تقليل الضرر من الملفات المشبوهة

ليس كل ملف مرفوع هدفه أن يكون صالحًا.
بعض الملفات تكون مصممة لاختبار حدود النظام. لذلك ضع سياسة واضحة:

  • عدد أنواع محدود.

  • حجم محدود.

  • اسم جديد.

  • مكان تخزين واضح.

  • منع التنفيذ.

  • تسجيل الأحداث.

حتى إن مرّ ملف مشبوه، يجب ألا يحصل على مساحة كبيرة من الحرية داخل النظام.

فحص الملفات الثنائية والمحتوى الفعلي

في بعض الحالات، الامتداد وMIME type لا يكفيان.
مثلاً بعض الملفات قد تحتوي على كود HTML أو JavaScript داخل نصوص أو وصفات مرفوعة. إذا كنت ستعرض هذه الملفات لاحقًا داخل الصفحة، فهنا يجب أن تكون حذرًا جدًا.

قاعدة مهمة

  • لا تعرض محتوى الملف مباشرة في HTML بدون تعقيم مناسب.

  • لا تفتح الملفات المرفوعة داخل متصفح المستخدم إن لم يكن ذلك ضروريًا.

  • لا تسمح بتنزيلها باسم قد يربك المتصفح أو يغير السلوك المتوقع.

التعامل مع الملفات الكبيرة جدًا

الملفات الكبيرة تحتاج معاملة خاصة.
إذا كان التطبيق يسمح بالفيديو أو الأرشيفات الكبيرة، فقد تحتاج إلى:

  • رفع على أجزاء Chunked Upload.

  • تقييد عدد الرفعات في الوقت نفسه.

  • استخدام queue لمعالجة الملفات بعد الرفع.

  • التأكد من مساحة القرص المتبقية.

في التطبيقات البسيطة، ابدأ بالحد الصارم على الحجم.
في المشاريع الكبيرة، لا تترك الرفع الكبير دون إدارة.

الرفع على أجزاء: هل هو ضروري؟

ليس دائمًا.
الرفع على أجزاء مفيد عندما:

  • تكون الملفات ضخمة جدًا.

  • الاتصال غير مستقر.

  • تريد دعم استكمال الرفع.

لكن هذا يضيف تعقيدًا.
إذا كان مشروعك صغيرًا أو متوسطًا، فغالبًا لن تحتاجه.
أبسط حل آمن أفضل من حل معقد غير مكتمل.

التعامل مع أسماء الملفات الطويلة أو الغريبة

قد يرفع المستخدم ملفًا باسم طويل جدًا جدًا أو يحتوي على أحرف غير متوقعة.
لذلك، حتى لو احتفظت بالاسم الأصلي، من الأفضل تطبيعه:

<?php
function sanitizeFileName(string $name): string {
    $name = preg_replace('/[^\w\-. ]+/u', '', $name);
    $name = preg_replace('/\s+/', '_', $name);
    return trim($name, '_');
}

لكن تذكر:
هذا مفيد للاحتفاظ باسم “نظيف” للعرض أو الأرشفة، وليس كبديل عن الاسم العشوائي الفعلي للتخزين.

منع تضارب الأسماء

لو سمحت بالحفظ باسم أصلي، ستواجه مشكلة التضارب.
ماذا لو رفع مستخدمان نفس الاسم؟
أو رفع نفس المستخدم الملف نفسه مرتين؟
الأفضل أن يكون الاسم المخزن عشوائيًا أو مبنيًا على معرف فريد.

مثال أفضل

<?php
$newFileName = uniqid('file_', true) . '.' . $extension;

لكن uniqid() ليس أقوى من random_bytes() من حيث العشوائية، لذلك في الملفات الحساسة الأفضل استخدام random_bytes().

تخزين الملفات الخاصة بطريقة آمنة

إذا كان الملف لا يجب أن يظهر مباشرة للمستخدم، فلا تضعه داخل مجلد عام.
ضعه في مكان لا يمكن الوصول إليه عبر URL، ثم أنشئ سكربت تحميل يحترم صلاحيات المستخدم.

مثال فكرة عامة

  • الملف محفوظ في storage/private/

  • المستخدم يطلب الملف من download.php?id=123

  • السكربت يتحقق من صلاحياته

  • ثم يرسل الملف بـ headers مناسبة

هذا أفضل بكثير من وضع رابط مباشر للجميع.

مثال على عرض ملف خاص عبر PHP

<?php
session_start();
require_once 'db.php';

$fileId = (int)($_GET['id'] ?? 0);

$stmt = $pdo->prepare("SELECT file_path, original_name, mime_type FROM uploads WHERE id = ?");
$stmt->execute([$fileId]);
$file = $stmt->fetch();

if (!$file) {
    die('الملف غير موجود.');
}

if (!isset($_SESSION['user_id'])) {
    die('غير مصرح.');
}

$path = __DIR__ . '/' . $file['file_path'];

if (!file_exists($path)) {
    die('الملف غير متوفر.');
}

header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: attachment; filename="' . basename($file['original_name']) . '"');
header('Content-Length: ' . filesize($path));

readfile($path);
exit;

هذا مثال مبسط.
في المشاريع الحقيقية ستحتاج إلى مزيد من التحقق من الصلاحيات والملكية.

كيف تمنع XSS من أسماء الملفات؟

لو عرضت اسم الملف داخل الصفحة، لا تفعل ذلك مباشرة.
استخدم:

<?= htmlspecialchars($file['original_name'], ENT_QUOTES, 'UTF-8') ?>

لأن الاسم قد يحتوي على رموز HTML أو JavaScript لو كان خبيثًا أو غريبًا.

التعامل مع الصور المعاد رفعها

أحيانًا تقول: “أنا أسمح فقط بالصورة، إذن أنا آمن.”
ليس تمامًا.
الصورة قد تكون:

  • صورة حقيقية لكن اسمها أو مسارها خطر.

  • صورة مع بيانات إضافية metadata.

  • ملفًا تم التلاعب به ليثير مشكلة أثناء المعالجة.

إذا كنت ستعالج الصور لاحقًا، مثل تغيير الحجم أو القص أو الضغط، فاستخدم مكتبات موثوقة وتحقق من كل خطوة.

إعادة إنتاج الصورة عبر الخادم

طريقة جيدة أحيانًا هي:

  1. قبول الصورة.

  2. التحقق منها.

  3. إعادة إنشائها من جديد عبر GD أو Imagick.

  4. حفظ النسخة الجديدة.

بهذه الطريقة تتخلص من الكثير من البيانات غير المرغوبة داخل الملف الأصلي.

مثال مبسط باستخدام GD

<?php
$tmp = $_FILES['user_file']['tmp_name'];
$imageInfo = getimagesize($tmp);

if ($imageInfo === false) {
    die('ليست صورة صالحة.');
}

switch ($imageInfo['mime']) {
    case 'image/jpeg':
        $image = imagecreatefromjpeg($tmp);
        break;
    case 'image/png':
        $image = imagecreatefrompng($tmp);
        break;
    case 'image/gif':
        $image = imagecreatefromgif($tmp);
        break;
    default:
        die('نوع الصورة غير مدعوم.');
}

if (!$image) {
    die('فشل معالجة الصورة.');
}

$newPath = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.jpg';
imagejpeg($image, $newPath, 90);
imagedestroy($image);

هذه الفكرة ممتازة إذا كنت تريد توحيد الصور وتقليل المخاطر المرتبطة بالمحتوى الأصلي.

التعامل مع multiple file uploads

أحيانًا تحتاج إلى رفع أكثر من ملف في نفس الوقت.
هنا يزداد التعقيد قليلًا، لأن $_FILES تصبح بنية مختلفة.

النموذج

<form method="POST" enctype="multipart/form-data">
    <input type="file" name="files[]" multiple>
    <button type="submit">رفع الملفات</button>
</form>

مثال المعالجة

<?php
if (!empty($_FILES['files']['name'][0])) {
    $count = count($_FILES['files']['name']);

    for ($i = 0; $i < $count; $i++) {
        $file = [
            'name' => $_FILES['files']['name'][$i],
            'type' => $_FILES['files']['type'][$i],
            'tmp_name' => $_FILES['files']['tmp_name'][$i],
            'error' => $_FILES['files']['error'][$i],
            'size' => $_FILES['files']['size'][$i],
        ];

        // نفس خطوات التحقق السابقة لكل ملف
    }
}

في الملفات المتعددة، يجب أن تكون أكثر صرامة.
قد تسمح بملف واحد وتمنع الآخر، أو ترفض الكل إذا فشل واحد فقط، بحسب منطق التطبيق.

تسجيل الأحداث Logging

في الأنظمة الجادة، من المهم تسجيل محاولات الرفع:

  • من حاول الرفع؟

  • متى؟

  • ما نوع الملف؟

  • هل نجح أم فشل؟

  • ما سبب الرفض؟

لكن لا تسجل معلومات حساسة بشكل مباشر.
سجل ما تحتاجه لمراجعة الأمان فقط.

مثال بسيط

<?php
function logUploadAttempt(string $message): void {
    file_put_contents(__DIR__ . '/upload.log', date('Y-m-d H:i:s') . ' - ' . $message . PHP_EOL, FILE_APPEND);
}

يمكنك تسجيل:

logUploadAttempt("User {$userId} uploaded file: {$originalName}");

أو:

logUploadAttempt("Blocked suspicious upload from IP: {$_SERVER['REMOTE_ADDR']}");

ما الأخطاء الشائعة التي يقع فيها المطورون؟

هناك أخطاء تتكرر كثيرًا، حتى لدى من لديهم خبرة متوسطة.

1. الثقة في الامتداد فقط

الملف قد يكون مزيفًا.

2. الثقة في MIME type القادم من المتصفح

غير موثوق به.

3. استخدام اسم الملف الأصلي للحفظ

قد يكون خطرًا أو يسبب تضاربًا.

4. حفظ الملفات داخل مجلد عام وتنفيذها

هذا باب خطر جدًا.

5. عدم تحديد حجم للملف

يفتح الباب لاستهلاك الموارد.

6. نسيان التحقق من error

قد تكون هناك مشكلة في الرفع أصلًا.

7. عرض اسم الملف أو محتواه دون تعقيم

قد يسبب XSS.

8. عدم استخدام CSRF token

قد يسمح بطلبات مزيفة.

9. قبول كل الأنواع لأن “المستخدمين يحتاجون المرونة”

المرونة بلا ضوابط تتحول إلى ثغرة.

10. تجاهل سجل الأخطاء

أحيانًا ترى الخطر في السجل قبل أن تراه في الواجهة.

نموذج نهائي أكثر تنظيمًا

هذا مثال أكثر شمولًا لرفع ملف مع حماية أفضل، لكنه ما زال مبسطًا بما يكفي للتطوير.

<?php
session_start();
require_once 'db.php';

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$errors = [];
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $csrf = $_POST['csrf_token'] ?? '';

    if (!hash_equals($_SESSION['csrf_token'], $csrf)) {
        $errors[] = 'فشل التحقق الأمني.';
    } elseif (!isset($_FILES['user_file'])) {
        $errors[] = 'لم يتم إرسال الملف.';
    } else {
        $file = $_FILES['user_file'];

        if ($file['error'] !== UPLOAD_ERR_OK) {
            $errors[] = 'حدث خطأ أثناء رفع الملف.';
        } else {
            $maxSize = 3 * 1024 * 1024;
            if ($file['size'] > $maxSize) {
                $errors[] = 'الملف أكبر من الحد المسموح.';
            }

            $originalName = $file['name'];
            $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

            $allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf'];
            if (!in_array($extension, $allowedExtensions, true)) {
                $errors[] = 'امتداد الملف غير مسموح.';
            }

            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mimeType = finfo_file($finfo, $file['tmp_name']);
            finfo_close($finfo);

            $allowedMimeTypes = [
                'image/jpeg',
                'image/png',
                'application/pdf',
            ];

            if (!in_array($mimeType, $allowedMimeTypes, true)) {
                $errors[] = 'نوع الملف غير مسموح.';
            }

            if (empty($errors)) {
                $uploadDir = __DIR__ . '/uploads/';
                if (!is_dir($uploadDir)) {
                    mkdir($uploadDir, 0755, true);
                }

                $storedName = bin2hex(random_bytes(16)) . '.' . $extension;
                $destination = $uploadDir . $storedName;

                if (move_uploaded_file($file['tmp_name'], $destination)) {
                    $stmt = $pdo->prepare("INSERT INTO uploads (original_name, stored_name, mime_type, file_size, file_path) VALUES (?, ?, ?, ?, ?)");
                    $stmt->execute([
                        $originalName,
                        $storedName,
                        $mimeType,
                        $file['size'],
                        'uploads/' . $storedName,
                    ]);

                    $success = 'تم رفع الملف بنجاح.';
                } else {
                    $errors[] = 'فشل حفظ الملف.';
                }
            }
        }
    }
}
?>

<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
    <meta charset="UTF-8">
    <title>رفع ملف آمن</title>
</head>
<body>
    <h2>رفع ملف بشكل آمن</h2>

    <?php if (!empty($errors)): ?>
        <div>
            <?php foreach ($errors as $error): ?>
                <p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>

    <?php if ($success): ?>
        <p><?= htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?></p>
    <?php endif; ?>

    <form method="POST" enctype="multipart/form-data">
        <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
        <input type="file" name="user_file">
        <button type="submit">رفع</button>
    </form>
</body>
</html>

تحسينات إضافية تستحق التفكير

إذا كان مشروعك أكبر، فهذه أفكار قد تحتاجها:

  • فحص الفيروسات بعد الرفع باستخدام أداة خارجية.

  • تخزين الملفات في S3 أو تخزين سحابي.

  • استخدام queue لمعالجة الملفات الثقيلة.

  • إعادة تسمية الصور ونسخها بعد المعالجة.

  • توليد thumbnails.

  • تسجيل IP الخاص بالرفع.

  • ربط الملفات بحساب المستخدم.

  • حذف الملفات غير المستخدمة تلقائيًا.

  • التحقق من الصلاحيات قبل التحميل أو الحذف.

  • دعم رفع الملفات الكبيرة على أجزاء.

هل يجب استخدام مكتبة جاهزة؟

في المشاريع الكبيرة، قد يكون استخدام مكتبة أو إطار عمل أفضل من كتابة كل شيء يدويًا.
لكن حتى لو استخدمت مكتبة، يجب أن تفهم المبادئ الأساسية:

  • لا تثق بالامتداد فقط.

  • لا تثق باسم الملف.

  • لا تثق في MIME type المرسل من العميل.

  • لا تحفظ في مجلد قابل للتنفيذ.

  • لا تهمل الحجم.

  • لا تهمل CSRF.

  • لا تهمل الحماية من XSS.

المعرفة الأساسية هي ما يجعلك تفهم إن كانت المكتبة فعلًا تحميك أم أنها فقط تعطيك شعورًا زائفًا بالأمان.

لمسة أخيرة من الواقع

أحيانًا تبدو هذه التفاصيل كثيرة جدًا على ميزة مثل رفع ملف. لكن الحقيقة أن أي ملف مرفوع هو ضيف غريب يدخل إلى منزلك الرقمي.
هل ستفتح له الباب بسرعة فقط لأنه قال إنه “صورة”؟
أم ستسأله أولًا، وتفحص هويته، وتحفظه في مكان مناسب، ثم تقرر أين تضعه؟

هذا هو التفكير الصحيح في الأمان:
ليس القلق الزائد، بل الاحترام المنطقي للحدود.

الخاتمة

رفع الملفات في PHP ليس صعبًا، لكنه يحتاج انضباطًا.
السهولة الحقيقية ليست في كتابة move_uploaded_file()، بل في بناء سلسلة من الفحوصات تمنع الملف الخاطئ من التسلل، وتحافظ على التطبيق نظيفًا وآمنًا وقابلًا للصيانة.

إذا أردت أن تلخص أفضل الممارسات في جملة واحدة، فهي هذه:
تحقق من النوع، وتحقق من الحجم، وغيّر الاسم، واحفظ في المكان الصحيح، ومنع التنفيذ، ولا تثق في شيء يأتي من المستخدم دون فحص.

عندما تتبع هذا الأسلوب، ستشعر أن نظام الرفع أصبح أكثر هدوءًا وثقة، وأنك لم تعد تضع ملفات المستخدمين في الخادم كأنك ترمي أوراقًا في درج مفتوح.
بل أصبحت تبني نظامًا يفهم أن كل ملف قد يكون بريئًا، وقد لا يكون كذلك، وأن دورك كمطوّر هو أن تجعل البراءة هي الشرط، لا الافتراض.

#رفع الملفات في PHP #أمان رفع الملفات PHP #حماية رفع الملفات #PHP file upload security #أفضل ممارسات رفع الملفات #التحقق من الملفات في PHP #حماية من رفع الملفات الخبيثة #رفع الصور في PHP #رفع PDF في PHP