0

EzCode # Bài 18 — CRUD khóa học và upload ảnh

Học xong bài này bạn sẽ:

  • Viết đủ bốn thao tác tạo, đọc, sửa, xóa cho khóa học
  • Xử lý upload file an toàn: kiểm tra loại, kiểm tra dung lượng, đổi tên file
  • Hiểu vì sao phải kiểm tra quyền sở hữu ở máy chủ dù giao diện đã ẩn nút

Cần học trước: Bài 17


1. Vấn đề đặt ra

Bài 17 cho giảng viên xem được khóa học của mình. Nhưng danh sách đó do bạn thêm tay vào database — giảng viên chưa tự tạo được gì.

Bài này làm ba việc còn lại của CRUD, và mỗi việc mở ra một vấn đề mới:

Việc Vấn đề mới
Tạo khóa học Phải nhận được file ảnh từ người dùng — thứ mà $_POST không làm được
Sửa khóa học Giữ ảnh cũ nếu không chọn ảnh mới, và xóa ảnh cũ nếu có ảnh mới
Xóa khóa học Bảo đảm giảng viên A không xóa được khóa của giảng viên B

Upload file là chỗ dễ bị tấn công nhất trong toàn bộ project. Nếu cho phép tải lên file .php, kẻ xấu chiếm được cả máy chủ. Bài này dạy bạn ba lớp phòng thủ.


2. Kiến thức mới

2.1. CRUD

Bốn thao tác cơ bản với dữ liệu, viết tắt từ tiếng Anh:

Chữ Nghĩa SQL Phương thức Model
Create Tạo mới INSERT create()
Read Đọc SELECT getAll(), getById()
Update Sửa UPDATE update()
Delete Xóa DELETE delete()

Bạn đã làm phần Read từ Bài 10. Bài này làm ba phần còn lại.

2.2. enctype="multipart/form-data"

Form bình thường gửi dữ liệu dưới dạng chuỗi văn bản. File thì không phải văn bản — nó là dữ liệu nhị phân, có thể vài megabyte.

Muốn gửi file, form bắt buộc phải khai báo:

<form method="POST" enctype="multipart/form-data">
    <input type="file" name="image">
</form>

Thiếu enctype là sai lầm phổ biến nhất khi làm upload. Form vẫn gửi, nút vẫn bấm được, không có thông báo lỗi nào — nhưng $_FILES rỗng hoàn toàn.

Người mới thường ngồi kiểm tra code PHP hàng giờ mà không nghĩ tới việc lỗi nằm ở một thuộc tính HTML.

Ba chữ multipart nghĩa là "nhiều phần": trình duyệt chia yêu cầu thành nhiều đoạn, mỗi ô một đoạn, và file được gửi nguyên dạng nhị phân trong đoạn của nó.

2.3. Mảng $_FILES

Khi form có file, PHP tạo mảng $_FILES. Với ô name="image", bạn có:

Khóa Chứa gì Ví dụ
$_FILES['image']['name'] Tên file gốc trên máy người dùng anh-khoa-hoc.png
$_FILES['image']['type'] Loại file do trình duyệt khai báo image/png
$_FILES['image']['tmp_name'] Đường dẫn tạm trên máy chủ C:\xampp\tmp\php7A3.tmp
$_FILES['image']['error'] Mã lỗi, 0 là thành công 0
$_FILES['image']['size'] Kích thước tính bằng byte 245678

Cảnh báo quan trọng: $_FILES['image']['type'] KHÔNG đáng tin.

Giá trị đó do trình duyệt gửi lên, và kẻ tấn công sửa được dễ dàng. Họ tải lên file virus.php và khai báo type là image/png.

Vì vậy không bao giờ kiểm tra loại file bằng cột type. Phải kiểm tra bằng phần mở rộng của tên file, hoặc tốt hơn nữa là đọc nội dung thật của file.

Hằng UPLOAD_ERR_OK có giá trị 0. Kiểm tra $_FILES['image']['error'] === UPLOAD_ERR_OK rõ nghĩa hơn viết === 0.

Vài mã lỗi khác đáng biết:

Hằng Nghĩa
UPLOAD_ERR_INI_SIZE File lớn hơn giới hạn trong php.ini
UPLOAD_ERR_PARTIAL File chỉ tải lên được một phần
UPLOAD_ERR_NO_FILE Người dùng không chọn file nào

UPLOAD_ERR_NO_FILE xảy ra rất thường — người dùng chỉ sửa tên khóa học mà không đổi ảnh. Vì vậy điều kiện === UPLOAD_ERR_OK bao quanh toàn bộ khối xử lý là bắt buộc.

2.4. Ba lớp kiểm tra file

Lớp 1 — kiểm tra phần mở rộng bằng danh sách trắng:

$fileInfo = pathinfo($_FILES['image']['name']);
$extension = strtolower($fileInfo['extension']);

$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array($extension, $allowedExtensions)) {
    $errors[] = "Chỉ chấp nhận file ảnh: JPG, PNG, GIF, WEBP";
}

pathinfo() tách một đường dẫn thành các phần:

pathinfo("anh-khoa-hoc.PNG");
// ['dirname' => '.', 'basename' => 'anh-khoa-hoc.PNG',
//  'extension' => 'PNG', 'filename' => 'anh-khoa-hoc']

strtolower() đổi về chữ thường để PNG, Png, png đều khớp.

Vì sao dùng danh sách trắng chứ không phải danh sách đen?

Danh sách đen — "cấm .php, .exe, .sh" — luôn thiếu sót. Bạn cấm .php thì còn .php3, .php5, .phtml, .phar. Bạn cấm hết thì máy chủ có thể cấu hình chạy đuôi khác.

Danh sách trắng thì ngược lại: chỉ những gì bạn cho phép rõ ràng mới qua. Muốn thêm loại file mới thì phải sửa code — an toàn theo mặc định.

Đây cùng nguyên tắc với switch cho ORDER BY ở Bài 12 mục 2.6.

Lớp 2 — kiểm tra dung lượng:

if ($_FILES['image']['size'] > 5 * 1024 * 1024) {
    $errors[] = "File ảnh không được lớn hơn 5MB";
}

5 * 1024 * 1024 là 5.242.880 byte. Viết dạng phép nhân dễ đọc hơn viết số trần.

Không giới hạn thì người dùng tải lên file 2GB và làm đầy ổ đĩa máy chủ.

Chú ý PHP còn có giới hạn riêng trong php.ini: upload_max_filesize mặc định là 2MB. Nên dù code cho phép 5MB, file 3MB vẫn bị PHP chặn trước với mã UPLOAD_ERR_INI_SIZE.

Lớp 3 — đổi tên file:

$newFileName = uniqid() . '_' . time() . '.' . $extension;

uniqid() sinh chuỗi 13 ký tự dựa trên thời gian tính bằng micro giây, ví dụ 6891ec228cd19.

time() là số giây từ 1970.

Ghép lại được tên như 6891ec228cd19_1754393634.png — chính là những tên file bạn thấy trong public/img/courses/ của repo.

Ba lý do phải đổi tên:

  1. Tránh trùng. Hai người cùng tải lên anh.png thì file sau đè file trước.
  2. Tránh ký tự lạ. Tên file có dấu tiếng Việt, dấu cách, hay ký tự đặc biệt gây lỗi trên một số hệ điều hành.
  3. Chặn tấn công đường dẫn. Kẻ xấu đặt tên file là ../../index.php để ghi đè file hệ thống. Đổi tên là vô hiệu hóa hoàn toàn.

2.5. move_uploaded_file()

if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadPath)) {
    $image = $newFileName;
} else {
    $errors[] = "Không thể upload file. Vui lòng thử lại.";
}

File tải lên nằm ở thư mục tạm và sẽ bị PHP xóa khi script kết thúc. Muốn giữ lại phải chuyển nó sang chỗ khác.

Vì sao dùng move_uploaded_file() chứ không phải copy() hay rename()?

Vì move_uploaded_file() kiểm tra thêm một điều: file nguồn có thực sự là file vừa được tải lên qua HTTP không.

Nếu không có kiểm tra đó, kẻ tấn công có thể lừa script chép một file hệ thống bất kỳ — chẳng hạn C:\xampp\htdocs\config.php — sang thư mục công khai và đọc được nội dung.

Quy tắc: xử lý file tải lên thì luôn dùng move_uploaded_file().

Hàm này trả về true hoặc false. Luôn kiểm tra kết quả — nó thất bại khi thư mục đích không có quyền ghi hoặc ổ đĩa đầy.

2.6. Lưu tên file, không lưu ảnh

$image = $newFileName;    // chỉ tên: "6891ec228cd19_1754393634.png"

Database lưu tên file, ổ đĩa lưu nội dung file.

Bạn có thể lưu cả ảnh vào database dưới dạng dữ liệu nhị phân, nhưng đừng làm vậy:

Lưu tên file Lưu nội dung vào database
Kích thước database Nhỏ, sao lưu nhanh Phình to rất nhanh
Tốc độ hiển thị Máy chủ web phục vụ trực tiếp Phải truy vấn rồi mới trả về
Dùng CDN Được Không

Nhược điểm duy nhất là hai nơi có thể lệch nhau: xóa dòng trong database mà quên xóa file, hoặc ngược lại.

2.7. Giữ ảnh cũ khi sửa

$image = $course['image']; // Giữ lại ảnh cũ nếu không upload mới
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
    // ...xử lý ảnh mới, gán lại $image...
}

Dòng đầu tiên là mấu chốt. Nó gán sẵn tên ảnh cũ; nếu người dùng không chọn ảnh mới thì khối if không chạy và $image giữ nguyên giá trị cũ.

Thiếu dòng này, $image sẽ rỗng và câu UPDATE xóa mất tên ảnh — khóa học mất ảnh chỉ vì giảng viên sửa giá.

2.8. Xóa ảnh cũ bằng unlink()

if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadPath)) {
    // Xóa ảnh cũ nếu có
    if (!empty($course['image'])) {
        $oldImagePath = $uploadDir . $course['image'];
        if (file_exists($oldImagePath)) {
            unlink($oldImagePath);
        }
    }
    $image = $newFileName;
}

unlink() xóa một file trên ổ đĩa. Không có nó, mỗi lần đổi ảnh là bỏ lại một file mồ côi.

Thứ tự ở đây rất quan trọng: xóa ảnh cũ sau khi ảnh mới đã chuyển thành công. Nếu xóa trước mà chuyển thất bại, bạn mất cả hai.

file_exists() kiểm tra trước khi xóa — nếu file đã bị xóa tay từ trước, unlink() sẽ sinh cảnh báo.

2.9. Kiểm tra quyền sở hữu

if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
    header("Location: ?ctrl=teacher&act=courses");
    exit();
}

Hai vế: khóa học không tồn tại, hoặc khóa học không thuộc về người đang đăng nhập.

Vì sao cần, khi giao diện đã chỉ hiện khóa học của chính họ?

Vì id nằm trên URL, và giảng viên A chỉ cần gõ ?ctrl=teacher&act=deleteCourse&id=5 là xóa khóa học của giảng viên B.

Constructor ở Bài 17 chỉ kiểm tra "bạn có phải giảng viên không", không kiểm tra "khóa học này có phải của bạn không". Đó là hai câu hỏi khác nhau.

Nguyên tắc: ẩn nút trên giao diện không phải là bảo mật.

Kiểm tra quyền phải nằm ở máy chủ, trong Controller, ngay trước khi thao tác với dữ liệu. Đây là loại lỗ hổng có tên riêng: truy cập trực tiếp đối tượng không kiểm soát — cùng loại với vấn đề ở Bài 16 mục 2.2.

EzCode làm đúng ở cả editCourse() và deleteCourse(). Đây là điểm mạnh.

2.10. Xác nhận bằng JavaScript

function deleteCourse(courseId) {
    if (confirm('Bạn có chắc chắn muốn xóa khóa học này?')) {
        window.location.href = '?ctrl=teacher&act=deleteCourse&id=' + courseId;
    }
}

confirm() hiện hộp thoại Có/Không và trả về true hoặc false.

Đây chỉ là lớp giao diện, không phải bảo mật. Người dùng gõ thẳng địa chỉ vào thanh URL là bỏ qua hoàn toàn. Bảo mật thật vẫn là kiểm tra quyền sở hữu ở mục 2.9.

Một vấn đề thiết kế đáng bàn: xóa dữ liệu bằng liên kết GET là không đúng chuẩn HTTP. Theo quy ước, GET chỉ nên đọc dữ liệu, còn thao tác thay đổi phải dùng POST hoặc DELETE.

Hậu quả thực tế: nếu trình duyệt hoặc một tiện ích nào đó tự động tải trước các liên kết trên trang để tăng tốc, nó sẽ vô tình xóa sạch khóa học của bạn.

Cách đúng là dùng một form POST nhỏ cho nút xóa.


3. Áp dụng vào EzCode

3.1. Thêm ba phương thức CRUD vào Model

Chèn vào Models/Courses.php trước dấu } cuối.

📁 Models/Courses.php — sửa

  function create($teacher_id, $category_id, $title, $description, $price, $image)
  {
    $sql = "INSERT INTO courses (teacher_id, category_id, title, description, price, image, status) 
            VALUES (?, ?, ?, ?, ?, ?, 1)";
    return $this->db->insert($sql, $teacher_id, $category_id, $title, $description, $price, $image);
  }

  function update($id, $category_id, $title, $description, $price, $image, $status)
  {
    $sql = "UPDATE courses SET category_id=?, title=?, description=?, price=?, image=?, status=? WHERE id=?";
    return $this->db->update($sql, $category_id, $title, $description, $price, $image, $status, $id);
  }

  function delete($id)
  {
    $sql = "DELETE FROM courses WHERE id=?";
    return $this->db->delete($sql, $id);
  }

Trong create(), cột status được viết cứng là 1 chứ không phải dấu ?. Nghĩa là khóa học mới luôn hiện ngay. Hợp lý — giảng viên tạo khóa học là để bán, không phải để ẩn.

create() có 7 cột nhưng chỉ 6 dấu ? vì status là hằng số. Đếm kỹ khi viết loại câu này.

Trong update(), $id là tham số cuối cùng vì nó ở mệnh đề WHERE đứng sau. Đây là chỗ rất dễ nhầm, đã cảnh báo ở Bài 16 mục 2.7.

Chú ý update() không cập nhật teacher_id. Đúng — chủ sở hữu khóa học không được đổi qua form.

delete() có WHERE id=?. Thiếu mệnh đề này là xóa sạch bảng.

⚠️ Lưu ý về khóa ngoại. Bảng enrollments có khóa ngoại trỏ tới courses.id và không có ON DELETE CASCADE (Bài 7 mục 2.5).

Nghĩa là khóa học đã có học viên đăng ký thì không xóa được. MySQL từ chối với lỗi:

Cannot delete or update a parent row: a foreign key constraint fails

Lỗi này sẽ bị catch trong Database::delete() nuốt mất, chỉ echo ra giữa trang. Người dùng thấy một dòng lỗi SQL khó hiểu thay vì thông báo tử tế.

Ngược lại, bảng payments có CASCADE, nên lịch sử thanh toán sẽ bị xóa theo. Sự thiếu nhất quán này đã được nêu ở Bài 7.

3.2. Thêm createCourse vào Controller

📁 Controllers/TeacherController.php — sửa

    public function createCourse()
    {
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $title = $_POST['title'] ?? '';
            $description = $_POST['description'] ?? '';
            $price = $_POST['price'] ?? 0;
            $category_id = $_POST['category_id'] ?? '';
            
            $errors = [];
            
            if (empty($title)) $errors[] = "Tên khóa học không được để trống";
            if (empty($description)) $errors[] = "Mô tả không được để trống";
            if ($price <= 0) $errors[] = "Giá khóa học phải lớn hơn 0";
            if (empty($category_id)) $errors[] = "Vui lòng chọn danh mục";
            
            // Xử lý upload file
            $image = '';
            if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
                $uploadDir = 'public/img/courses/';
                
                // Tạo thư mục nếu chưa tồn tại
                if (!is_dir($uploadDir)) {
                    mkdir($uploadDir, 0755, true);
                }
                
                $fileInfo = pathinfo($_FILES['image']['name']);
                $extension = strtolower($fileInfo['extension']);
                
                // Kiểm tra loại file
                $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
                if (!in_array($extension, $allowedExtensions)) {
                    $errors[] = "Chỉ chấp nhận file ảnh: JPG, PNG, GIF, WEBP";
                }
                
                // Kiểm tra kích thước file (tối đa 5MB)
                if ($_FILES['image']['size'] > 5 * 1024 * 1024) {
                    $errors[] = "File ảnh không được lớn hơn 5MB";
                }
                
                if (empty($errors)) {
                    // Tạo tên file mới để tránh trùng lặp
                    $newFileName = uniqid() . '_' . time() . '.' . $extension;
                    $uploadPath = $uploadDir . $newFileName;
                    
                    if (move_uploaded_file($_FILES['image']['tmp_name'], $uploadPath)) {
                        $image = $newFileName;
                    } else {
                        $errors[] = "Không thể upload file. Vui lòng thử lại.";
                    }
                }
            }
            
            if (empty($errors)) {
                include_once "./Models/Courses.php";
                $coursesModel = new Courses();
                
                $result = $coursesModel->create($_SESSION['user_id'], $category_id, $title, $description, $price, $image);
                
                if ($result) {
                    $_SESSION['success'] = "Tạo khóa học thành công!";
                    header("Location: ?ctrl=teacher&act=courses");
                    exit();
                } else {
                    $_SESSION['error'] = "Có lỗi xảy ra khi tạo khóa học";
                }
            } else {
                $_SESSION['error'] = implode(", ", $errors);
            }
        }
        
        // Lấy danh sách categories
        include_once "./Models/Category.php";
        $categoryModel = new Category();
        $categories = $categoryModel->getAll();
        
        include_once "./Views/teacher_course_create.php";
    }

$_SESSION['user_id'] được truyền vào làm teacher_id — lấy từ session chứ không từ form, đúng nguyên tắc ở Bài 16 mục 2.2. Giảng viên không thể tạo khóa học mang tên người khác.

mkdir($uploadDir, 0755, true) tạo thư mục nếu chưa có. Tham số thứ hai 0755 là quyền truy cập kiểu Unix: chủ sở hữu đọc-ghi-chạy, người khác chỉ đọc-chạy. Trên Windows thì tham số này bị bỏ qua. Tham số thứ ba true cho phép tạo cả thư mục cha nếu thiếu.

Điều kiện if ($price <= 0) hoạt động vì PHP tự chuyển chuỗi "300000" thành số khi so sánh với 0. Nhưng nếu người dùng gõ chữ, "abc" <= 0 cho kết quả false ở PHP 8 — nghĩa là chữ lọt qua được. Ô type="number" trong form chặn phần lớn trường hợp, nhưng không phải tất cả.

3.3. Thêm editCourse

📁 Controllers/TeacherController.php — sửa

    public function editCourse($id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($id);
        
        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

Ba dòng đầu dùng getByIdForTeacher() — bản không lọc status = 1 mà bạn viết ở Bài 13 mục 3.1.

Đây là lựa chọn đúng: giảng viên phải sửa được khóa học đang ẩn để bật lại.

Phần còn lại của phương thức giống hệt createCourse(), chỉ khác ba điểm:

  1. $image = $course['image']; giữ ảnh cũ — mục 2.7
  2. Có khối unlink() xóa ảnh cũ — mục 2.8
  3. Có thêm trường $status từ form

Phần thân dài 90 dòng và lặp lại gần như nguyên văn code upload của createCourse(). Đây là trùng lặp đáng kể — bài tập sẽ bàn cách tách ra.

3.4. Thêm deleteCourse

📁 Controllers/TeacherController.php — sửa

    public function deleteCourse($id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getById($id);
        
        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }
        
        $result = $coursesModel->delete($id);
        
        if ($result) {
            $_SESSION['success'] = "Xóa khóa học thành công!";
        } else {
            $_SESSION['error'] = "Có lỗi xảy ra khi xóa khóa học";
        }
        
        header("Location: ?ctrl=teacher&act=courses");
        exit();
    }

⚠️ Lưu ý — hãy so sánh dòng thứ ba của phương thức này với editCourse().

editCourse() gọi getByIdForTeacher($id) — không lọc trạng thái. deleteCourse() gọi getById($id) — có lọc AND c.status = 1.

Hậu quả: giảng viên không xóa được khóa học đang ẩn. getById() trả về false, điều kiện !$course đúng, và họ bị đá về danh sách mà không có thông báo nào giải thích.

Người dùng sẽ bấm Xóa, xác nhận, rồi thấy trang tải lại với khóa học vẫn còn nguyên. Không hiểu chuyện gì xảy ra.

Đây là lỗi số 8 trong Bài 22. Bài tập 2 dưới đây chính là bản vá — sửa đúng một từ.

Phương thức manageLessons() ở Bài 19 mắc cùng một lỗi.

Ngoài ra deleteCourse() không xóa file ảnh của khóa học. Mỗi lần xóa khóa học là bỏ lại một file mồ côi trong public/img/courses/.

3.5. Tạo View form tạo khóa học

📁 Views/teacher_course_create.php — tạo mới

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>

    <div class="dashboard-content">
        <h2>Tạo khóa học mới</h2>

        <?php if (isset($_SESSION['error'])): ?>
            <div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
                <?= $_SESSION['error'] ?>
            </div>
            <?php unset($_SESSION['error']); ?>
        <?php endif; ?>

        <form method="POST" class="create-course-form" enctype="multipart/form-data">
            <div class="form-column">
                <div>
                    <label for="title">Tên khóa học *</label>
                    <input type="text" id="title" name="title" value="<?= isset($_POST['title']) ? htmlspecialchars($_POST['title']) : '' ?>" required>
                </div>

                <div>
                    <label for="category_id">Danh mục *</label>
                    <select id="category_id" name="category_id" required>
                        <option value="">Chọn danh mục</option>
                        <?php foreach ($categories as $category): ?>
                            <option value="<?= $category['id'] ?>" <?= (isset($_POST['category_id']) && $_POST['category_id'] == $category['id']) ? 'selected' : '' ?>>
                                <?= htmlspecialchars($category['name']) ?>
                            </option>
                        <?php endforeach; ?>
                    </select>
                </div>

                <div>
                    <label for="price">Giá khóa học (VNĐ) *</label>
                    <input type="number" id="price" name="price" value="<?= isset($_POST['price']) ? $_POST['price'] : '' ?>" min="0" required>
                </div>

                <div>
                    <label for="image">Hình ảnh khóa học</label>
                    <div class="file-upload-container">
                        <input type="file" id="image" name="image" accept="image/*" onchange="previewImage(this)">
                        <div class="file-info">
                            <small>Chấp nhận: JPG, PNG, GIF, WEBP (tối đa 5MB)</small>
                        </div>
                        <div id="image-preview" class="image-preview" style="display: none;">
                            <img id="preview-img" src="" alt="Preview">
                            <button type="button" onclick="removeImage()" class="remove-image-btn">×</button>
                        </div>
                    </div>
                </div>
            </div>

            <div class="form-column">
                <div>
                    <label for="description">Mô tả khóa học *</label>
                    <textarea id="description" name="description" rows="8" required><?= isset($_POST['description']) ? htmlspecialchars($_POST['description']) : '' ?></textarea>
                </div>
            </div>

            <button type="submit">Tạo khóa học</button>
        </form>
    </div>
</main>

<script>
function previewImage(input) {
    const preview = document.getElementById('image-preview');
    const previewImg = document.getElementById('preview-img');
    
    if (input.files && input.files[0]) {
        const reader = new FileReader();
        
        reader.onload = function(e) {
            previewImg.src = e.target.result;
            preview.style.display = 'block';
        }
        
        reader.readAsDataURL(input.files[0]);
    } else {
        preview.style.display = 'none';
    }
}

function removeImage() {
    const input = document.getElementById('image');
    const preview = document.getElementById('image-preview');
    
    input.value = '';
    preview.style.display = 'none';
}
</script>

Thuộc tính enctype="multipart/form-data" nằm ở dòng form — thứ bắt buộc ở mục 2.2.

Thẻ <textarea> khác <input>: giá trị nằm giữa hai thẻ, không phải trong thuộc tính value. Vì vậy không được để dấu cách hay xuống dòng giữa <textarea> và <?=, nếu không giá trị sẽ có khoảng trắng thừa ở đầu.

accept="image/*" lọc sẵn trong hộp thoại chọn file. Chỉ là tiện ích giao diện, người dùng vẫn chọn được file khác.

Hàm previewImage() dùng FileReader để đọc file thành chuỗi dữ liệu rồi gán vào src của thẻ ảnh. Nhờ vậy người dùng xem trước ảnh mà không cần tải lên máy chủ.

Ô giá không có htmlspecialchars:

value="<?= isset($_POST['price']) ? $_POST['price'] : '' ?>"

Khác với ô tiêu đề. Vì đây là ô số nên tác giả cho rằng không cần. Nhưng $_POST['price'] vẫn có thể chứa bất kỳ chuỗi nào nếu kẻ tấn công gửi form thẳng.

⚠️ Lưu ý: Đây là một chỗ thiếu htmlspecialchars() trên dữ liệu người dùng, thuộc lỗi số 10 trong Bài 22. Nó nhỏ hơn các chỗ khác vì chỉ chính người gửi mới thấy, nhưng vẫn nên vá.

3.6. Tạo View form sửa khóa học

📁 Views/teacher_course_edit.php — tạo mới

<main class="dashboard-page">
    <?php include_once "./Views/layout_teacher_sidebar.php"; ?>
    
    <div class="dashboard-content">
        <h2>Chỉnh sửa khóa học: <?= htmlspecialchars($course['title']) ?></h2>
        
        <?php if (isset($_SESSION['error'])): ?>
            <div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
                <?= $_SESSION['error'] ?>
            </div>
            <?php unset($_SESSION['error']); ?>
        <?php endif; ?>
        
        <form method="POST" class="create-course-form" enctype="multipart/form-data">
            <div class="form-column">
                <div>
                    <label for="title">Tên khóa học *</label>
                    <input type="text" id="title" name="title" value="<?= htmlspecialchars($course['title']) ?>" required>
                </div>
                
                <div>
                    <label for="category_id">Danh mục *</label>
                    <select id="category_id" name="category_id" required>
                        <option value="">Chọn danh mục</option>
                        <?php foreach ($categories as $category): ?>
                            <option value="<?= $category['id'] ?>" <?= ($course['category_id'] == $category['id']) ? 'selected' : '' ?>>
                                <?= htmlspecialchars($category['name']) ?>
                            </option>
                        <?php endforeach; ?>
                    </select>
                </div>
                
                <div>
                    <label for="price">Giá khóa học (VNĐ) *</label>
                    <input type="number" id="price" name="price" value="<?= $course['price'] ?>" min="0" required>
                </div>
                
                <div>
                    <label for="image">Hình ảnh khóa học</label>
                    <div class="file-upload-container">
                        <input type="file" id="image" name="image" accept="image/*" onchange="previewImage(this)">
                        <div class="file-info">
                            <small>Chấp nhận: JPG, PNG, GIF, WEBP (tối đa 5MB)</small>
                        </div>
                        
                        <!-- Hiển thị ảnh hiện tại nếu có -->
                        <?php if (!empty($course['image'])): ?>
                            <div class="current-image">
                                <p><strong>Ảnh hiện tại:</strong></p>
                                <img src="public/img/courses/<?= htmlspecialchars($course['image']) ?>" alt="Current image" style="max-width: 200px; max-height: 150px; border-radius: 8px;">
                            </div>
                        <?php endif; ?>
                        
                        <div id="image-preview" class="image-preview" style="display: none;">
                            <img id="preview-img" src="" alt="Preview">
                            <button type="button" onclick="removeImage()" class="remove-image-btn">×</button>
                        </div>
                    </div>
                </div>
                
                <div>
                    <label for="status">Trạng thái</label>
                    <select id="status" name="status">
                        <option value="1" <?= $course['status'] == 1 ? 'selected' : '' ?>>Hoạt động</option>
                        <option value="0" <?= $course['status'] == 0 ? 'selected' : '' ?>>Ẩn</option>
                    </select>
                </div>
            </div>
            
            <div class="form-column">
                <div>
                    <label for="description">Mô tả khóa học *</label>
                    <textarea id="description" name="description" rows="8" required><?= htmlspecialchars($course['description']) ?></textarea>
                </div>
            </div>
            
            <button type="submit">Cập nhật khóa học</button>
        </form>
    </div>
</main>

Chú ý: file này còn có khối <script> với hai hàm previewImage() và removeImage() giống hệt file tạo khóa học. Hãy chép nguyên khối đó từ mục 3.5 vào cuối file.

Khác biệt chính so với form tạo: mọi ô đều điền sẵn từ $course chứ không phải $_POST, và có thêm ô chọn Trạng thái.

Khối "Ảnh hiện tại" chỉ hiện khi khóa học đã có ảnh, cho người dùng thấy ảnh cũ trước khi quyết định đổi.


4. Chạy thử

Đăng nhập bằng tài khoản giảng viên.

Thử 1 — Tạo khóa học. Mở ?ctrl=teacher&act=createCourse.

Điền tên, chọn danh mục, nhập giá 150000, chọn một file ảnh .png, viết mô tả. Khi chọn ảnh, xem trước hiện ra ngay — đó là previewImage().

Bấm "Tạo khóa học". Kết quả: về trang danh sách với khung xanh "Tạo khóa học thành công!", và khóa học mới ở đầu bảng.

Kiểm chứng ổ đĩa: mở public/img/courses/, bạn thấy một file mới tên dạng 68f3a1b2c4d5e_1756789012.png.

Kiểm chứng database: phpMyAdmin, bảng courses, dòng cuối có image đúng bằng tên file đó.

Thử 2 — Chặn file sai loại. Tạo khóa học mới nhưng chọn một file .txt.

Trước tiên bạn phải bỏ accept="image/*" bằng F12, vì hộp thoại chọn file lọc sẵn.

Kết quả: Chỉ chấp nhận file ảnh: JPG, PNG, GIF, WEBP.

Thử 3 — Sửa khóa học. Bấm "Sửa" ở khóa học vừa tạo.

Mọi ô đã điền sẵn, và có khối "Ảnh hiện tại" hiện ảnh bạn vừa tải lên.

Đổi giá thành 200000, không chọn ảnh mới, bấm Cập nhật. Kết quả: giá đổi, ảnh vẫn còn — đó là dòng $image = $course['image']; ở mục 2.7.

Thử 4 — Đổi ảnh. Sửa lại, lần này chọn ảnh mới.

Sau khi lưu, mở public/img/courses/ — file cũ đã biến mất, chỉ còn file mới. Đó là unlink() ở mục 2.8.

Thử 5 — Ẩn khóa học. Sửa khóa học, đổi Trạng thái thành "Ẩn", lưu.

  • Trang chủ và trang danh sách: khóa đó biến mất
  • Bảng quản lý giảng viên: vẫn còn, cột Trạng thái ghi "Ẩn" màu đỏ

Thử 6 — Kiểm chứng lỗi số 8. Với khóa học đang ẩn đó, bấm nút Xóa và xác nhận.

Kết quả: trang tải lại, khóa học vẫn còn, không có thông báo nào.

Bạn vừa gặp lỗi số 8. Nguyên nhân: deleteCourse() gọi getById() vốn lọc status = 1.

Bây giờ đổi Trạng thái về "Hoạt động" rồi xóa lại — lần này thành công.

Thử 7 — Kiểm chứng quyền sở hữu. Vào bảng courses trong phpMyAdmin, ghi lại id của một khóa học của giảng viên khác, ví dụ khóa id = 1 thuộc teacher_id = 1.

Gõ thẳng vào thanh địa chỉ:

?ctrl=teacher&act=editCourse&id=1

Bạn bị đá về danh sách. Kiểm tra quyền sở hữu ở mục 2.9 đã chặn.

Thử tiếp ?ctrl=teacher&act=deleteCourse&id=1 — cũng bị chặn. Rất tốt.

Thử 8 — Khóa học có học viên. Thử xóa một khóa học đã có dòng trong enrollments.

Bạn sẽ thấy một dòng lỗi SQL thô hiện giữa trang:

DELETE FROM courses WHERE id=? SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row...

Đây là khóa ngoại đang bảo vệ dữ liệu, cộng với cách xử lý lỗi thô của Database::delete() ở Bài 8.


5. Bài tập

Bài tập 1

Thêm lớp kiểm tra thứ tư cho ảnh: kích thước tối thiểu 300x200 điểm ảnh.

Dùng getimagesize($_FILES['image']['tmp_name']) — hàm này trả về mảng có [0] là chiều rộng và [1] là chiều cao.

Suy nghĩ thêm: hàm này còn có tác dụng bảo mật gì ngoài việc đo kích thước?

Bài tập 2

Vá lỗi số 8.

Sửa deleteCourse() dùng getByIdForTeacher() thay cho getById().

Kiểm chứng lại bằng đúng cách ở Thử 6.

Sau đó tìm xem trong TeacherController còn phương thức nào mắc cùng lỗi này không.

Bài tập 3

Thêm phương thức Courses::toggleStatus($id) để bật/tắt nhanh trạng thái khóa học, cùng một nút trong bảng quản lý.

Yêu cầu: nút phải kiểm tra quyền sở hữu như deleteCourse().


6. Đáp án

Đáp án bài tập 1

📄 Controllers/TeacherController.php — thêm vào khối kiểm tra file, trong cả createCourse() và editCourse()

                // Kiểm tra kích thước ảnh tối thiểu
                $kichThuoc = getimagesize($_FILES['image']['tmp_name']);
                if ($kichThuoc === false) {
                    $errors[] = "File không phải là ảnh hợp lệ";
                } elseif ($kichThuoc[0] < 300 || $kichThuoc[1] < 200) {
                    $errors[] = "Ảnh phải có kích thước tối thiểu 300x200 điểm ảnh";
                }

Tác dụng bảo mật của getimagesize():

Hàm này đọc nội dung thật của file để xác định kích thước. Nếu file không phải ảnh, nó trả về false.

Đây là lớp phòng thủ mạnh hơn hẳn việc kiểm tra phần mở rộng.

Hãy hình dung một cuộc tấn công: kẻ xấu tạo file virus.php chứa mã độc, rồi đổi tên thành virus.png. Kiểm tra phần mở rộng ở mục 2.4 sẽ cho qua vì đuôi là .png.

Nhưng getimagesize() mở file ra, không tìm thấy cấu trúc ảnh hợp lệ, và trả về false. Cuộc tấn công bị chặn.

Nguyên tắc quan trọng: kiểm tra dựa trên nội dung thật luôn mạnh hơn kiểm tra dựa trên tên.

Ba lớp phòng thủ tốt nhất cho upload ảnh, xếp theo độ mạnh tăng dần:

  1. Kiểm tra phần mở rộng — chặn người dùng bất cẩn
  2. Kiểm tra getimagesize() — chặn file giả dạng
  3. Lưu file ngoài thư mục web, hoặc cấu hình máy chủ không chạy PHP trong thư mục upload — chặn cả khi hai lớp trên bị vượt qua

EzCode chỉ có lớp 1. Thêm lớp 2 là cải tiến đáng giá.

Đáp án bài tập 2

📄 Controllers/TeacherController.php

    public function deleteCourse($id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($id);   // SỬA: getById -> getByIdForTeacher
        
        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }
        // ...phần còn lại giữ nguyên...

Sửa đúng một từ. Kiểm chứng: ẩn một khóa học rồi xóa — lần này thành công.

Phương thức nào còn mắc cùng lỗi?

manageLessons($course_id) ở Bài 19. Nó cũng gọi getById():

    public function manageLessons($course_id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getById($course_id);      // CÙNG LỖI

Hậu quả: giảng viên không quản lý được bài học của khóa học đang ẩn.

Đây là tình huống thực tế rất hay gặp: giảng viên ẩn khóa học đi để soạn thêm bài, rồi phát hiện mình không vào được phần soạn bài. Vòng luẩn quẩn.

createLesson($course_id) cũng gọi getById() — mắc cùng lỗi.

Tổng cộng ba phương thức, và Bài 22 sẽ vá cả ba.

Bài học rút ra: khi bạn tạo ra hai phương thức gần giống nhau như getById() và getByIdForTeacher(), bạn đã tạo ra một cái bẫy. Người bảo trì sau này — kể cả chính bạn ba tháng sau — sẽ gọi nhầm.

Cách thiết kế an toàn hơn là dùng một phương thức với tham số rõ ràng:

  function getById($id, $chiLayDangHien = true)
  {
    $sql = "SELECT c.*, u.name AS teacher_name FROM courses c 
            INNER JOIN users u ON c.teacher_id = u.id WHERE c.id=?";
    if ($chiLayDangHien) {
        $sql .= " AND c.status = 1";
    }
    return $this->db->queryOne($sql, $id);
  }

Khi đó lời gọi tự nói lên ý định: getById($id, false) rõ ràng là "lấy cả khóa đang ẩn".

Đáp án bài tập 3

📄 Models/Courses.php

  function toggleStatus($id)
  {
    $sql = "UPDATE courses SET status = 1 - status WHERE id=?";
    return $this->db->update($sql, $id);
  }

Mẹo status = 1 - status: nếu đang là 1 thì thành 0, đang là 0 thì thành 1. Không cần đọc giá trị cũ về PHP rồi mới ghi lại — MySQL làm luôn trong một câu.

📄 Controllers/TeacherController.php

    public function toggleStatus($id)
    {
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($id);

        // Kiểm tra quyền sở hữu
        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        if ($coursesModel->toggleStatus($id)) {
            $_SESSION['success'] = $course['status']
                ? "Đã ẩn khóa học"
                : "Đã hiện khóa học";
        } else {
            $_SESSION['error'] = "Có lỗi xảy ra";
        }

        header("Location: ?ctrl=teacher&act=courses");
        exit();
    }

📄 Views/teacher_course.php — thêm nút vào cột Thao tác

                                <a href="?ctrl=teacher&act=toggleStatus&id=<?= $course['id'] ?>" class="btn btn-sm">
                                    <?= $course['status'] ? 'Ẩn' : 'Hiện' ?>
                                </a>

Ba điều đáng chú ý:

Thứ nhất, dùng getByIdForTeacher() ngay từ đầu. Vì mục đích của nút này chính là bật lại khóa đang ẩn — dùng getById() là tự chặn chính mình.

Thứ hai, thông báo dựa trên trạng thái CŨ. Biến $course được lấy trước khi đổi, nên $course['status'] là giá trị cũ. Nếu cũ là 1 (đang hiện) thì sau khi đổi sẽ là ẩn, nên thông báo "Đã ẩn khóa học". Logic này dễ nhầm ngược.

Thứ ba, phương thức này để public nên gọi được từ URL — đúng như thiết kế. Và nó tự động được constructor bảo vệ, đúng như Bài 17 mục 2.1.

Một điểm cần nhớ: nút này cũng dùng liên kết GET để thay đổi dữ liệu, mắc cùng vấn đề đã nêu ở mục 2.10. Với một thao tác dễ hoàn tác như bật/tắt thì rủi ro thấp, nhưng nguyên tắc vẫn là POST cho mọi thao tác thay đổi.


🎉 Bạn vừa hoàn thành Phần III. Website đã có đủ chức năng cho cả học viên lẫn giảng viên.

➡️ Bài tiếp theo: Bài 19 — Quản lý bài học và học viên

⬅️ Về mục lục · Bài trước


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí